diff --git a/.eslintrc.js b/.eslintrc.cjs similarity index 84% rename from .eslintrc.js rename to .eslintrc.cjs index 76f220db..57ed377a 100644 --- a/.eslintrc.js +++ b/.eslintrc.cjs @@ -17,5 +17,7 @@ module.exports = { 'import/extensions': ['error', { js: 'always', }], + 'max-len': ['error', { "code": 200 }], + 'function-paren-newline': 'off', }, }; diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index 27b3c3d7..9e966580 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -13,5 +13,6 @@ jobs: node-version: '16' #required for npm 8 or later. - run: npm install - run: npm run lint + - run: npm test env: CI: true diff --git a/.mocharc.json b/.mocharc.json new file mode 100644 index 00000000..3b0fdf10 --- /dev/null +++ b/.mocharc.json @@ -0,0 +1,3 @@ +{ + "recursive": true +} diff --git a/blocks/agent-search/builders/tags.js b/blocks/agent-search/builders/tags.js index d5bcaf58..2838541b 100644 --- a/blocks/agent-search/builders/tags.js +++ b/blocks/agent-search/builders/tags.js @@ -28,6 +28,7 @@ export const addSelectionTag = (wrapper, filter, value) => { `; wrapper.querySelector('.selection-tags-list').append(li); }; + /** * Builds the Container for the search bar selections. * diff --git a/blocks/header/header.css b/blocks/header/header.css index 5af1aa86..2b27c00c 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -23,7 +23,7 @@ body.light-nav { / 1fr min-content; align-items: start; overflow-y: scroll; - z-index: 50; + z-index: 1000; } .header.block nav[aria-expanded="true"] { @@ -259,6 +259,7 @@ body.light-nav { } .header.block nav .nav-hamburger .open { + display: block; position: absolute; top: 0; left: 0; @@ -444,7 +445,7 @@ body.light-nav { right: 0; height: 5px; background-color: var(--white); - z-index: 10; + z-index: 1010; } .header.block nav[aria-expanded="true"] .nav-sections > ul > li > ul { diff --git a/blocks/hero/search/agent.css b/blocks/hero/search/agent.css index c35d9b6c..a8d71a0a 100644 --- a/blocks/hero/search/agent.css +++ b/blocks/hero/search/agent.css @@ -13,9 +13,6 @@ .hero.block .agent-search .selection-tags { position: absolute; -} - -.hero.block .agent-search .selection-tags { padding: 0; } diff --git a/blocks/hero/search/home-delayed.js b/blocks/hero/search/home-delayed.js deleted file mode 100644 index 6655b4bb..00000000 --- a/blocks/hero/search/home-delayed.js +++ /dev/null @@ -1,227 +0,0 @@ -import { getMetadata } from '../../../scripts/aem.js'; -import { BREAKPOINTS } from '../../../scripts/scripts.js'; -import { - close as closeCountrySelect, - getSelected as getSelectedCountry, -} from '../../shared/search-countries/search-countries.js'; -import { - abortSuggestions, - getSuggestions, - propertySearch, - DOMAIN, -} from '../../../scripts/apis/creg/creg.js'; -import { getSpinner } from '../../../scripts/util.js'; -import SearchType from '../../../scripts/apis/creg/SearchType.js'; -import SearchParameters from '../../../scripts/apis/creg/SearchParameters.js'; - -const noOverlayAt = BREAKPOINTS.medium; - -const MORE_INPUT_NEEDED = 'Please enter at least 3 characters.'; -const NO_SUGGESTIONS = 'No suggestions found. Please modify your search.'; -const SEARCHING_SUGGESTIONS = 'Looking up suggestions...'; - -const fixOverlay = () => { - if (noOverlayAt.matches) { - document.body.style.overflowY = 'hidden'; - } else { - document.body.style.overflowY = null; - } -}; - -const showFilters = (e) => { - e.preventDefault(); - e.stopPropagation(); - e.currentTarget.closest('form').classList.add('show-filters'); - if (!noOverlayAt.matches) { - document.body.style.overflowY = 'hidden'; - } -}; - -const closeFilters = (e) => { - e.preventDefault(); - e.stopPropagation(); - const thisForm = e.currentTarget.closest('form'); - thisForm.classList.remove('show-filters'); - thisForm.querySelectorAll('.select-wrapper.open').forEach((select) => { - select.classList.remove('open'); - }); - - if (!noOverlayAt.matches) { - document.body.style.overflowY = 'hidden'; - } -}; - -const selectClicked = (e) => { - e.preventDefault(); - e.stopPropagation(); - const wrapper = e.currentTarget.closest('.select-wrapper'); - const wasOpen = wrapper.classList.contains('open'); - const thisForm = e.currentTarget.closest('form'); - thisForm.querySelectorAll('.select-wrapper.open').forEach((select) => { - select.classList.remove('open'); - }); - closeCountrySelect(thisForm); - if (!wasOpen) { - wrapper.classList.add('open'); - } -}; - -const selectFilterClicked = (e) => { - e.preventDefault(); - e.stopPropagation(); - const count = e.currentTarget.textContent; - const wrapper = e.currentTarget.closest('.select-wrapper'); - wrapper.querySelector('.selected').textContent = count; - wrapper.querySelector('ul li.selected')?.classList.toggle('selected'); - e.currentTarget.classList.add('selected'); - wrapper.querySelector('select option[selected="selected"]')?.removeAttribute('selected'); - wrapper.querySelector(`select option[value="${count.replace('+', '')}"]`).setAttribute('selected', 'selected'); - wrapper.classList.toggle('open'); -}; - -const updateSuggestions = (suggestions, target) => { - // Keep the first item - required character entry count. - const first = target.querySelector(':scope li'); - target.replaceChildren(first, ...suggestions); -}; - -const buildSuggestions = (suggestions) => { - const lists = []; - suggestions.forEach((category) => { - const list = document.createElement('li'); - list.classList.add('list-title'); - list.textContent = category.displayText; - lists.push(list); - const ul = document.createElement('ul'); - list.append(ul); - category.results.forEach((result) => { - const li = document.createElement('li'); - li.setAttribute('category', category.searchType); - li.setAttribute('display', result.displayText); - li.setAttribute('query', result.QueryString); - li.textContent = result.SearchParameter; - ul.append(li); - }); - }); - - return lists; -}; - -/** - * Handles the input changed event for the text field. Will add suggestions based on user input. - * - * @param {Event} e the change event - * @param {HTMLElement} target the container in which to add suggestions - */ -const inputChanged = (e, target) => { - const { value } = e.currentTarget; - if (value.length > 0) { - e.currentTarget.closest('.search-bar').classList.add('show-suggestions'); - } else { - e.currentTarget.closest('.search-bar').classList.remove('show-suggestions'); - } - - if (value.length <= 2) { - abortSuggestions(); - target.querySelector(':scope > li:first-of-type').textContent = MORE_INPUT_NEEDED; - updateSuggestions([], target); - } else { - target.querySelector(':scope > li:first-of-type').textContent = SEARCHING_SUGGESTIONS; - getSuggestions(value, getSelectedCountry(e.currentTarget.closest('form'))) - .then((suggestions) => { - if (!suggestions) { - // Undefined suggestions means it was aborted, more input coming. - updateSuggestions([], target); - return; - } - if (suggestions.length) { - updateSuggestions(buildSuggestions(suggestions), target); - } else { - target.querySelector(':scope > li:first-of-type').textContent = NO_SUGGESTIONS; - } - }); - } -}; - -const suggestionSelected = (e, form) => { - const query = e.target.getAttribute('query'); - const keyword = e.target.getAttribute('display'); - if (!query) { - return; - } - form.querySelector('input[name="keyword"]').value = keyword; - form.querySelector('input[name="query"]').value = query; - form.querySelector('.search-bar').classList.remove('show-suggestions'); -}; - -const formSubmitted = async (e) => { - e.preventDefault(); - e.stopPropagation(); - - const spinner = getSpinner(); - const form = e.currentTarget.closest('form'); - form.prepend(spinner); - - const franchisee = getMetadata('office-id'); - const type = SearchType[form.querySelector('input[name="type"]').value]; - const query = form.querySelector('input[name="query"]').value; - const input = form.querySelector('input[name="keyword"]').value; - const params = new SearchParameters(type); - params.SearchInput = input; - if (query) { - params.populate(query); - } - - if (franchisee) { - params.franchisee = franchisee; - } - params.PageSize = 1; - propertySearch(params).then((results) => { - if (!results?.properties) { - // What to do here? - spinner.remove(); - return; - } - - const domain = results.vanityDomain || `https://${DOMAIN}`; - const searchPath = '/search'; - params.PageSize = SearchParameters.DEFAULT_PAGE_SIZE; - params.ApplicationType = results.ApplicationType || params.ApplicationType; - params.PropertyType = results.PropertyType || params.PropertyType; - window.location = `${domain}${searchPath}?${params.asQueryString()}`; - }); -}; - -function addEventListeners() { - const form = document.querySelector('.hero.block form.homes'); - - noOverlayAt.addEventListener('change', fixOverlay); - - form.querySelectorAll('button[type="submit"]').forEach((button) => { - button.addEventListener('click', formSubmitted); - }); - - form.querySelector('button.filter').addEventListener('click', showFilters); - - form.querySelectorAll('button.close').forEach((button) => { - button.addEventListener('click', closeFilters); - }); - - form.querySelectorAll('.select-wrapper .selected').forEach((button) => { - button.addEventListener('click', selectClicked); - }); - - form.querySelectorAll('.select-wrapper .select-items li').forEach((li) => { - li.addEventListener('click', selectFilterClicked); - }); - - const suggestionsTarget = form.querySelector('.suggester-input .suggester-results'); - form.querySelector('.suggester-input input').addEventListener('input', (e) => { - inputChanged(e, suggestionsTarget); - }); - suggestionsTarget.addEventListener('click', (e) => { - suggestionSelected(e, form); - }); -} - -addEventListeners(); diff --git a/blocks/hero/search/home.css b/blocks/hero/search/home.css index 974e2e30..962430b2 100644 --- a/blocks/hero/search/home.css +++ b/blocks/hero/search/home.css @@ -116,10 +116,16 @@ right: 0; } -.hero.block .content .homes .filters .select-wrapper.open .selected::after { +.hero.block .content .homes .filters .select-wrapper.open > .selected::after { content: '\f0d8'; } +.hero.block .content .homes .filters .select-wrapper .selected span { + font-size: var(--body-font-size-xs); + color: var(--input-placeholder); +} + + .hero.block .content .homes .filters .select-wrapper .select-items { display: none; position: absolute; diff --git a/blocks/hero/search/home.js b/blocks/hero/search/home.js index 3d89f4e5..fb89fad2 100644 --- a/blocks/hero/search/home.js +++ b/blocks/hero/search/home.js @@ -1,61 +1,83 @@ import { build as buildCountrySelect, } from '../../shared/search-countries/search-countries.js'; +import { getMetadata, loadScript } from '../../../scripts/aem.js'; +import { getSpinner } from '../../../scripts/util.js'; +import { BED_BATHS, buildFilterSelect, getPlaceholder } from '../../shared/search/util.js'; +import Search, { SEARCH_URL } from '../../../scripts/apis/creg/search/Search.js'; +import { metadataSearch } from '../../../scripts/apis/creg/creg.js'; -function observeForm() { - const script = document.createElement('script'); - script.type = 'module'; - script.src = `${window.hlx.codeBasePath}/blocks/hero/search/home-delayed.js`; - document.head.append(script); +async function observeForm(e) { + const form = e.target.closest('form'); + try { + const mod = await import(`${window.hlx.codeBasePath}/blocks/shared/search/suggestion.js`); + mod.default(form); + } catch (error) { + // eslint-disable-next-line no-console + console.log('failed to load suggestion library', error); + } + e.target.removeEventListener('focus', observeForm); } -/** - * Creates a Select dropdown for filtering search. - * @param {String} name - * @param {String} placeholder - * @param {number} number - * @returns {HTMLDivElement} - */ -function buildSelect(name, placeholder, number) { - const wrapper = document.createElement('div'); - wrapper.classList.add('select-wrapper'); - wrapper.innerHTML = ` - -
${placeholder}
- - `; +async function submitForm(e) { + e.preventDefault(); + e.stopPropagation(); + + const spinner = getSpinner(); + const form = e.currentTarget.closest('form'); + form.prepend(spinner); + + const type = form.querySelector('input[name="type"]'); - const select = wrapper.querySelector('select'); - const ul = wrapper.querySelector('ul'); - for (let i = 1; i <= number; i += 1) { - const option = document.createElement('option'); - const li = document.createElement('li'); - li.setAttribute('role', 'option'); - - option.value = `${i}`; - // eslint-disable-next-line no-multi-assign - option.textContent = li.textContent = `${i}+`; - select.append(option); - ul.append(li); + let search = new Search(); + if (type && type.value) { + try { + const mod = await import(`${window.hlx.codeBasePath}/scripts/apis/creg/search/types/${type.value}Search.js`); + if (mod.default) { + // eslint-disable-next-line new-cap + search = new mod.default(); + } + } catch (error) { + // eslint-disable-next-line no-console + console.log(`failed to load Search Type for ${type.value}`, error); + } } - return wrapper; -} + search.populateFromSuggestion(new URLSearchParams(form.querySelector('input[name="query"]').value)); + search.input = form.querySelector('input[name="keyword"]').value; -function getPlaceholder(country) { - if (country && country !== 'US') { - return 'Enter City'; + search.minPrice = form.querySelector('input[name="minPrice"]').value; + search.maxPrice = form.querySelector('input[name="maxPrice"]').value; + search.minBedrooms = form.querySelector('select[name="bedrooms"]').value; + search.minBathrooms = form.querySelector('select[name="bathrooms"]').value; + + const franchisee = getMetadata('office-id'); + if (franchisee) { + search.franchisee = franchisee; } - return 'Enter City, Address, Zip/Postal Code, Neighborhood, School or MLS#'; + metadataSearch(search).then((results) => { + if (results) { + let url = ''; + if (window.location.href.includes('localhost')) { + url += `/search?${search.asURLSearchParameters()}`; + } else if (results.vanityDomain) { + if (getMetadata('vanity-domain') === results.vanityDomain) { + url += `/search?${search.asURLSearchParameters()}`; + } else { + url += `${results.vanityDomain}/search?${search.asCregURLSearchParameters()}`; + } + } else { + url = `https://www.bhhs.com${results.searchPath}/search?${search.asCregURLSearchParameters()}`; + } + window.location = url; + } + spinner.remove(); + }); } async function buildForm() { const form = document.createElement('form'); form.classList.add('homes'); - form.setAttribute('action', '/search'); + form.setAttribute('action', SEARCH_URL); form.innerHTML = `
@@ -97,15 +119,16 @@ async function buildForm() {
- ${buildSelect('MinBedroomsTotal', 'Bedrooms', 12).outerHTML} - ${buildSelect('MinBathroomsTotal', 'Bathrooms', 8).outerHTML} + ${buildFilterSelect('MinBedroomsTotal', 'Bedrooms', BED_BATHS).outerHTML} + ${buildFilterSelect('MinBathroomsTotal', 'Bathrooms', BED_BATHS).outerHTML}
-`; + `; + + const input = form.querySelector('.suggester-input input'); const changeCountry = (country) => { const placeholder = getPlaceholder(country); - const input = form.querySelector('.suggester-input input'); input.setAttribute('placeholder', placeholder); input.setAttribute('aria-label', placeholder); }; @@ -115,7 +138,16 @@ async function buildForm() { form.querySelector('.search-country-select-parent').append(select); } }); - window.setTimeout(observeForm, 3000); + + window.setTimeout(() => { + loadScript(`${window.hlx.codeBasePath}/blocks/hero/search/home/filters.js`, { type: 'module' }); + }, 3000); + input.addEventListener('focus', observeForm); + + form.querySelectorAll('button[type="submit"]').forEach((button) => { + button.addEventListener('click', submitForm); + }); + return form; } diff --git a/blocks/hero/search/home/filters.js b/blocks/hero/search/home/filters.js new file mode 100644 index 00000000..0f7bfb41 --- /dev/null +++ b/blocks/hero/search/home/filters.js @@ -0,0 +1,74 @@ +import { close as closeCountrySelect } from '../../../shared/search-countries/search-countries.js'; +import { BREAKPOINTS } from '../../../../scripts/scripts.js'; +import { closeOnBodyClick, filterItemClicked } from '../../../shared/search/util.js'; + +const noOverlayAt = BREAKPOINTS.medium; + +const fixOverlay = () => { + if (noOverlayAt.matches) { + document.body.style.overflowY = 'hidden'; + } else { + document.body.style.overflowY = null; + } +}; + +const showFilters = (e) => { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.closest('form').classList.add('show-filters'); + if (!noOverlayAt.matches) { + document.body.style.overflowY = 'hidden'; + } +}; + +const closeFilters = (e) => { + e.preventDefault(); + e.stopPropagation(); + const thisForm = e.currentTarget.closest('form'); + thisForm.classList.remove('show-filters'); + thisForm.querySelectorAll('.select-wrapper.open').forEach((select) => { + select.classList.remove('open'); + }); + document.body.style.overflowY = ''; +}; + +const updateExpanded = (wrapper) => { + const wasOpen = wrapper.classList.contains('open'); + const thisForm = wrapper.closest('form'); + thisForm.querySelectorAll('.open').forEach((item) => { + item.classList.remove('open'); + item.querySelector('[aria-expanded="true"]')?.setAttribute('aria-expanded', 'false'); + }); + if (!wasOpen) { + wrapper.classList.add('open'); + wrapper.querySelector('[aria-expanded="false"]')?.setAttribute('aria-expanded', 'true'); + } + closeOnBodyClick(thisForm); +}; + +function addEventListeners() { + const form = document.querySelector('.hero.block form.homes'); + noOverlayAt.addEventListener('change', fixOverlay); + + form.querySelector('button.filter').addEventListener('click', showFilters); + + form.querySelectorAll('button.close').forEach((button) => { + button.addEventListener('click', closeFilters); + }); + + form.querySelectorAll('.select-wrapper .selected').forEach((button) => { + button.addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + const thisForm = e.currentTarget.closest('form'); + closeCountrySelect(thisForm); + updateExpanded(e.currentTarget.closest('.select-wrapper')); + }); + }); + + form.querySelectorAll('.select-wrapper .select-items li').forEach((li) => { + li.addEventListener('click', filterItemClicked); + }); +} + +addEventListeners(); diff --git a/blocks/hero/search/search.css b/blocks/hero/search/search.css index 0e4ee323..7f83c4fa 100644 --- a/blocks/hero/search/search.css +++ b/blocks/hero/search/search.css @@ -26,7 +26,7 @@ border-top-right-radius: 0px; color: var(--primary-color); cursor: default; - font-weight: 600; + font-weight: var(--font-weight-semibold); } .hero.block > div .content .search .options .option[data-option="agents"] { @@ -77,17 +77,14 @@ background-color: var(--white); border: 1px solid var(--grey); box-shadow: 0 3px 9px 2px rgba(0 0 0 / 23%); - z-index: 10; line-height: 1.5; + z-index: 1000; } .hero.block .content .search-bar.show-suggestions .suggester-results { display: block; } -.hero.block .content .search-bar .suggester-results > li:first-child:not(:only-child){ - display: none; -} .hero.block .content .search-bar .suggester-results > li > ul { padding: 0 15px; @@ -96,16 +93,20 @@ .hero.block .content .search-bar .suggester-results > li > ul > li { padding: 8px 0; font-size: var(--body-font-size-m); - font-weight: 400; + font-weight: var(--font-weight-normal); text-transform: none; letter-spacing: normal; } +.hero.block .content .search-bar .suggester-results > li:first-child:not(:only-child){ + display: none; +} + .hero.block .content .search-bar .suggester-results .list-title { padding: 15px 15px 5px; font-family: var(--font-family-primary); font-size: var(--body-font-size-s); - font-weight: 700; + font-weight: var(--font-weight-bold); text-transform: uppercase; letter-spacing: .5px; } @@ -113,7 +114,6 @@ .hero.block .content .search-bar .search-submit { background: var(--primary-color); border: none; - color: var(--white); cursor: pointer; flex: unset; height: 35px; @@ -130,6 +130,7 @@ display: block; font-size: var(--body-font-size-xs); font-weight: var(--font-weight-bold); + color: var(--white); text-align: center; width: 100%; } @@ -143,7 +144,7 @@ @media screen and (min-width: 900px) { .hero.block .content .search-bar { - width: 620px; + width: 680px; padding: 10px; } } diff --git a/blocks/login/login.js b/blocks/login/login.js index 4367f789..7a40b4b1 100644 --- a/blocks/login/login.js +++ b/blocks/login/login.js @@ -72,7 +72,7 @@ function initLogin() { export default async function decorate(block) { i18n = await i18nLookup(); - + /* eslint-disable max-len */ block.innerHTML = `
@@ -139,6 +139,8 @@ export default async function decorate(block) {
`; + /* eslint-enable max-len */ + observeForm(); initLogin(); } diff --git a/blocks/property-listing/map-search.js b/blocks/property-listing/map-search.js deleted file mode 100644 index 3501c1d1..00000000 --- a/blocks/property-listing/map-search.js +++ /dev/null @@ -1,25 +0,0 @@ -import SearchType from '../../scripts/apis/creg/SearchType.js'; -import Search from './search.js'; - -/** - * Create and render property search based on a bounding box. - */ -export default class MapSearch extends Search { - more = false; - - #minLat; - - #minLon; - - #maxLat; - - #maxLon; - - constructor(minLat, minLon, maxLat, maxLon) { - super(SearchType.Map, SearchType.Map.paramBuilder(minLat, maxLat, minLon, maxLon)); - this.#minLat = minLat; - this.#minLon = minLon; - this.#maxLat = maxLat; - this.#maxLon = maxLon; - } -} diff --git a/blocks/property-listing/property-listing.css b/blocks/property-listing/property-listing.css index 91e4cca1..021d44fc 100644 --- a/blocks/property-listing/property-listing.css +++ b/blocks/property-listing/property-listing.css @@ -1,5 +1,5 @@ @import url('luxury-collection-template.css'); -@import url('./cards/cards.css'); +@import url('../shared/property/cards.css'); .property-listing.block { overflow: hidden; diff --git a/blocks/property-listing/property-listing.js b/blocks/property-listing/property-listing.js index b5587445..7c714bb4 100644 --- a/blocks/property-listing/property-listing.js +++ b/blocks/property-listing/property-listing.js @@ -1,64 +1,7 @@ import { getMetadata, readBlockConfig } from '../../scripts/aem.js'; -import ApplicationType from '../../scripts/apis/creg/ApplicationType.js'; -import SearchType, { searchTypeFor } from '../../scripts/apis/creg/SearchType.js'; -import PropertyType from '../../scripts/apis/creg/PropertyType.js'; -import MapSearch from './map-search.js'; -import RadiusSearch from './radius-search.js'; - -/* eslint-disable no-param-reassign */ -const buildListingTypes = (configEntry) => { - const types = []; - if (!configEntry) { - types.push(ApplicationType.FOR_SALE); - return types; - } - - const [, configStr] = configEntry; - if (configStr.match(/sale/i)) { - types.push(ApplicationType.FOR_SALE); - } - if (configStr.match(/rent/gi)) { - types.push(ApplicationType.FOR_RENT); - } - if (configStr.match(/pending/gi)) { - types.push(ApplicationType.PENDING); - } - if (configStr.match(/sold/gi)) { - types.push(ApplicationType.RECENTLY_SOLD); - } - return types; -}; -/* eslint-enable no-param-reassign */ - -const buildPropertyTypes = (configEntry) => { - const types = []; - if (!configEntry) { - types.push(PropertyType.CONDO_TOWNHOUSE); - types.push(PropertyType.SINGLE_FAMILY); - return types; - } - - const [, configStr] = configEntry; - if (configStr.match(/(condo|townhouse)/i)) { - types.push(PropertyType.CONDO_TOWNHOUSE); - } - if (configStr.match(/single\sfamily/gi)) { - types.push(PropertyType.SINGLE_FAMILY); - } - if (configStr.match(/commercial/gi)) { - types.push(PropertyType.COMMERCIAL); - } - if (configStr.match(/multi\s+family/gi)) { - types.push(PropertyType.MULTI_FAMILY); - } - if (configStr.match(/(lot|land)/gi)) { - types.push(PropertyType.LAND); - } - if (configStr.match(/(farm|ranch)/gi)) { - types.push(PropertyType.FARM); - } - return types; -}; +import { render as renderCards } from '../shared/property/cards.js'; +import Search from '../../scripts/apis/creg/search/Search.js'; +import { propertySearch } from '../../scripts/apis/creg/creg.js'; export default async function decorate(block) { // Find and process list type configurations. @@ -85,54 +28,12 @@ export default async function decorate(block) { block.innerHTML = ''; } - let search; - - const entries = Object.entries(config); - const type = searchTypeFor(entries.find(([k]) => k.match(/search.*type/i))[1]); - - if (type === SearchType.Map) { - const minLat = entries.find(([k]) => k.includes('min') && k.includes('lat'))[1]; - const maxLat = entries.find(([k]) => k.includes('max') && k.includes('lat'))[1]; - const minLon = entries.find(([k]) => k.includes('min') && k.includes('lon'))[1]; - const maxLon = entries.find(([k]) => k.includes('max') && k.includes('lon'))[1]; - search = new MapSearch(minLat, minLon, maxLat, maxLon); - } else if (type === SearchType.Radius) { - let lat = entries.find(([k]) => k.includes('lat'))[1]; - let lon = entries.find(([k]) => k.includes('lon'))[1]; - const radius = entries.find(([k]) => k.includes('distance'))[1]; - - // Go looking for the search parameters. - if (!lat) { - const urlParams = new URLSearchParams(window.location.search); - lat = urlParams.get('latitude'); - lon = urlParams.get('longitude'); - } - - search = new RadiusSearch(lat, lon, radius); - } else if (type === SearchType.Community) { - const { bbox } = window.liveby.geometry; - const minLon = Math.min(...bbox.map((e) => e[0])); - const maxLon = Math.max(...bbox.map((e) => e[0])); - const minLat = Math.min(...bbox.map((e) => e[1])); - const maxLat = Math.max(...bbox.map((e) => e[1])); - search = new MapSearch(minLat, minLon, maxLat, maxLon); - } else { - search = new MapSearch(0, 0, 0, 0); - } - - search.listingTypes = buildListingTypes(entries.find(([k]) => k.match(/listing.*type/i))); - search.propertyTypes = buildPropertyTypes(entries.find(([k]) => k.match(/property.*type/i))); - - search.isNew = !!entries.find(([k]) => k.match(/new/i)); - search.isOpenHouse = !!entries.find(([k]) => k.match(/open.*house/i)); - - [, search.minPrice] = entries.find(([k]) => k.match(/min.*price/i)) || []; - [, search.maxPrice] = entries.find(([k]) => k.match(/max.*price/i)) || []; - - [, search.pageSize] = entries.find(([k]) => k.match(/page.*size/i)) || []; - search.sortBy = config['sort-by']; - search.sortDirection = config['sort-direction']; - search.officeId = getMetadata('office-id'); - - await search.render(block, false); + const search = await Search.fromBlockConfig(config); + search.franchiseeCode = getMetadata('office-id'); + const list = document.createElement('div'); + list.classList.add('property-list-cards', `rows-${Math.floor(search.pageSize / 8)}`); + block.append(list); + propertySearch(search).then((results) => { + renderCards(list, results.properties); + }); } diff --git a/blocks/property-listing/radius-search.js b/blocks/property-listing/radius-search.js deleted file mode 100644 index 5fc78824..00000000 --- a/blocks/property-listing/radius-search.js +++ /dev/null @@ -1,22 +0,0 @@ -import SearchType from '../../scripts/apis/creg/SearchType.js'; -import Search from './search.js'; - -/** - * Create and render property search based on a point and radius. - */ -export default class RadiusSearch extends Search { - more = false; - - #lat; - - #lon; - - #radius; - - constructor(lat, lon, radius) { - super(SearchType.Radius, SearchType.Radius.paramBuilder(lat, lon, radius)); - this.#lat = lat; - this.#lon = lon; - this.#radius = radius; - } -} diff --git a/blocks/property-listing/search.js b/blocks/property-listing/search.js deleted file mode 100644 index a9fdac62..00000000 --- a/blocks/property-listing/search.js +++ /dev/null @@ -1,59 +0,0 @@ -import SearchParameters, { SortDirections, SortOptions } from '../../scripts/apis/creg/SearchParameters.js'; -import { render as renderCards } from './cards/cards.js'; -// eslint-disable-next-line no-unused-vars -import SearchType from '../../scripts/apis/creg/SearchType.js'; - -// function addMoreButton() { -// TODO: add this logic if there's supposed to be more results; -// } - -export default class Search { - #searchParams; - - /** - * Create a new Search object. (should not be called directly) - * - * @param {SearchType} type - * @param {string} params the result of the paramBuilder for the specified type. - */ - constructor(type, params) { - this.#searchParams = new SearchParameters(type, params); - } - - isNew; - - isOpenHouse; - - listingTypes; - - maxPrice; - - minPrice; - - officeId; - - pageSize; - - propertyTypes; - - sortBy; - - sortDirection; - - // eslint-disable-next-line no-unused-vars - async render(parent, enableMore = false) { - this.#searchParams.MinPrice = this.minPrice; - this.#searchParams.MaxPrice = this.maxPrice; - this.#searchParams.PageSize = this.pageSize || SearchParameters.DEFAULT_PAGE_SIZE; - this.#searchParams.sortBy = this.sortBy || SortOptions.DATE; - this.#searchParams.sortDirection = this.sortDirection || SortDirections.DESC; - this.#searchParams.propertyTypes = this.propertyTypes; - this.#searchParams.applicationTypes = this.listingTypes; - this.#searchParams.NewListing = this.isNew || this.#searchParams.NewListing; - this.#searchParams.OpenHouses = this.isOpenHouse ? '7' : undefined; - this.#searchParams.franchisee = this.officeId; - - await renderCards(this.#searchParams, parent); - // TODO: Enable the "Load More" for the Property Search page. - } -} diff --git a/blocks/property-result-listing/property-result-listing.css b/blocks/property-result-listing/property-result-listing.css deleted file mode 100644 index be4683ee..00000000 --- a/blocks/property-result-listing/property-result-listing.css +++ /dev/null @@ -1,350 +0,0 @@ -@import url('../property-listing/cards/cards.css'); -@import url('../property-search-bar/search-results-dropdown.css'); -@import url('../property-result-map/map.css'); - -.property-result-listing.block { - display: flex; - flex-wrap: wrap; -} - -.property-result-listing.block .search-results-loader { - position: relative; - overflow: hidden; - z-index: 0; -} - -.property-result-listing.block .search-results-loader-image.enter { - opacity: 1; -} - -.property-result-listing.block .search-results-loader-image.exit { - opacity: 0; - pointer-events: none; - z-index: -1; -} - -.property-result-listing.block .search-results-loader-image { - text-align: center; - margin: 0 !important; - z-index: 1; - height: 100%; - width: 100%; - display: flex; - align-items: flex-start; - justify-content: center; - background-color: var(--white); - transition: all 1s ease-in; -} - -.property-search-template.search-map-active .property-result-listing.block > div { - flex: 0 0 50%; - max-width: 50%; - padding: 0 15px; -} - -.property-result-listing.block .button-container { - display: flex; - margin-bottom: 1.5rem; -} - -.property-result-listing.block .hide { - display: none; -} - -.property-result-listing.block .property-list-cards { - display: grid; - grid-template: repeat(8, 1fr) / repeat(4, 1fr); - height: 3340px; - grid-gap: 20px; -} - -.search-map-active .property-result-listing.block .property-list-cards{ - height: 6520px; - grid-template: repeat(16, 1fr) / repeat(2, 1fr); -} - - -.property-result-listing.block .button-container a { - cursor:pointer; - font-family: var(--font-family-primary); - letter-spacing: 1px; - text-transform: uppercase; - text-decoration: none; - padding: 7px 25px; - font-size: var(--body-font-size-xs); - line-height: 1.5; - border-radius: 0; - background: var(--white); - color: var(--black); - display: inline-block; - font-weight: 400; - text-align: center; - white-space: nowrap; - vertical-align: middle; - border: 1px solid var(--black); -} - -.property-result-listing.block .button-container a:hover { - border-color: var(--grey); -} - -.property-result-listing.block .property-list-cards .property-labels .property-label.new-listing { - text-transform: initial; -} - -.property-result-listing.block [name="Page"].multiple-inputs .select-item{ - left: 0; - border: 1px solid var(--platinum); - max-height: 185px; - overflow-y: scroll; - overflow-x: hidden; - width: 93px; - display: block; -} - -.property-result-listing.block [name="Page"].multiple-inputs .select-item.hide { - display: none; -} - -.property-result-listing.block [name="Page"] { - display: flex; - justify-content: flex-end; -} - -.property-result-listing.block [name="Page"] .select-selected, -.property-result-listing.block [name="Page"] .search-results-dropdown .select-item li { - font-size: var(--body-font-size-xs); - letter-spacing: var(--letter-spacing-reg); - color: var(--body-color); - cursor: pointer; -} - -.property-result-listing.block [name="Page"] .select-wrapper { - position: relative; - width: 91px; -} - -.property-result-listing.block [name="Page"] .select-selected { - border: 1px solid var(--grey); - height: 35px; - line-height: 35px; - padding: 0 15px; - white-space: nowrap; -} - -.property-result-listing.block [name="Page"] .select-selected::after { - right: 5px; -} - -.property-result-listing.block [name="Page"] .search-results-dropdown .select-item li:first-child { - border-top: none; -} - -.property-result-listing.block [name="Page"] .search-results-dropdown .select-item li:last-child { - border-bottom: none; -} - -.property-result-listing.block .pagination-arrows { - display: flex; - justify-content: flex-end; - line-height: 32px; -} - -.property-result-listing.block .pagination-arrows .arrow { - border: 1px solid #ced4da; - position: relative; - height: 35px; - width: 35px; - padding: 4px; - cursor: pointer; - text-align: center -} - -.property-result-listing.block .pagination-arrows .arrow.disabled { - cursor: auto; - border: 1px solid #adb5bd; -} - -.property-result-listing.block .pagination-arrows .arrow.disabled::after { - border: solid #ced4da; - border-width: 0 2px 2px 0; -} - -.property-result-listing.block .pagination-arrows .arrow.prev { - margin-left: 0.5rem !important -} - -.property-result-listing.block .pagination-arrows .arrow::after { - content: ""; - border: solid black; - border-width: 0 2px 2px 0; - display: inline-block; - padding: 7px; -} - -.property-result-listing.block .pagination-arrows .arrow.prev::after { - transform: rotate(135deg) translate(-3px,-3px); -} - -.property-result-listing.block .pagination-arrows .arrow.next::after { - transform: rotate(-45deg) translate(-3px,-3px); -} - -.property-result-listing.block .disclaimer { - font-family: var(--font-family-primary); - font-size: var(--body-font-size-xs); - font-weight: var(--font-weight-normal); - letter-spacing: var(--letter-spacing-s); - color: var(--dark-grey); - line-height: var(--line-height-m); -} - -.property-result-listing.block .disclaimer hr { - height: 1px; - background: var(--grey); - width: 100%; - margin: 30px 0 0; - padding: 0; - display: block; - border: 0 -} - -.property-result-listing.block .disclaimer td[align="center"] { -text-align: center; -} - -.property-result-listing.block .disclaimer td[align="left"] { - text-align: left; -} - -.property-result-listing.block .disclaimer td[align="right"] { - text-align: right; -} - -.property-result-listing.block .disclaimer * { - font-size: var(--body-font-size-xs); -} - -.property-result-listing.block .disclaimer .text { - color: var(--body-color); - font-size: var(--body-font-size-xs); - line-height: var(--line-height-s); - margin: 30px 0; - column-gap: 50px; -} - -.property-result-listing.block .disclaimer .text-center { - text-align: center; -} - -.property-result-listing.block .disclaimer .text img { - height: auto; - margin: 0; - padding: 0; - width: 30% -} - -.property-result-listing.block .property-search-results-buttons { - display: flex; - position: fixed; - bottom: 0; - left: 0; - z-index: 5; - background: var(--white); - width: 100%; - justify-content: center; - padding: 10px 0; - box-shadow: 0 0 6px 0 rgb(0 0 0 / 23%); - cursor: pointer; -} - -.property-result-listing.block .property-search-results-buttons .btn.btn-map { - font-size: var(--body-font-size-xs); - height: 35px; - background: var(--white); - border: 1px solid var(--grey); - padding: 0 15px; - line-height: var(--line-height-xxl); - display: inline-block; -} - -.property-result-listing.block .property-search-results-buttons section:first-of-type { - margin-right: 16px; -} - -.property-result-listing.block .property-search-results-buttons .btn.btn-map span { - font-size: var(--body-font-size-xs); - color: var(--body-color); -} - -@media (min-width: 900px) { - .property-result-listing.block .button-container { - justify-content: flex-end; - } - - .property-result-listing.block [class*="disclaimer"] * { - font-family: var(--font-family-primary); - font-size: var(--body-font-size-xs); - color: var(--dark-grey); - line-height: var(--line-height-s); - letter-spacing: var(--letter-spacing-s); - opacity: 1; - } - - .property-result-listing.block [class*="disclaimer"] * p { - font-family: var(--font-family-primary); - font-size: var(--body-font-size-xs); - color: var(--dark-grey); - line-height: var(--line-height-s); - letter-spacing: var(--letter-spacing-s); - } - - .property-result-listing.block .property-search-results-buttons { - display: none; - } -} - -@media (max-width: 899px) { - .property-result-listing.block { - width: 100%; - flex-direction: column-reverse; - } - - .property-result-listing.block .property-list-cards { - grid-template: repeat(32, 1fr) / repeat(1, 1fr); - height: 13440px; - } - - .property-result-listing.block .property-list-cards .listing-tile { - width: 100%; - max-width: 100%; - } - - .search-map-active .property-result-listing.block { - width: 100%; - min-height: 800px; - } - - .search-map-active .property-result-listing.block .property-list-cards, - .search-map-active .property-result-listing.block [name="Page"], - .search-map-active .property-result-listing.block .property-list-cards .property-result-content .disclaimer { - display: none; - } - - .property-search-template.search-map-active .property-result-listing.block > div { - flex: 0 0 100%; - max-width: 100%; - padding: 0 15px; - } -} - - -@media (min-width: 1200px) { - .property-result-listing.block .property-info-wrapper { - font-size: var(--body-font-size-xl); - } - - .property-result-listing.block .property-list-cards { - grid-template: repeat(8, 1fr) / repeat(4, 1fr); - } -} diff --git a/blocks/property-result-listing/property-result-listing.js b/blocks/property-result-listing/property-result-listing.js deleted file mode 100644 index c72db6ab..00000000 --- a/blocks/property-result-listing/property-result-listing.js +++ /dev/null @@ -1,200 +0,0 @@ -import { createCard } from '../property-listing/cards/cards.js'; -import { - getDisclaimer, - getPropertiesCount, - getPropertyDetails, - getAllData, -} from '../../scripts/search/results.js'; -import { getValueFromStorage, searchProperty, setFilterValue } from '../property-search-bar/filter-processor.js'; -import renderMap from '../property-result-map/map.js'; -import { - showModal, -} from '../../scripts/util.js'; - -const event = new Event('onFilterChange'); -const ITEMS_PER_PAGE = 32; -function buildLoader() { - const wrapper = document.createElement('div'); - wrapper.classList.add('search-results-loader'); - wrapper.innerHTML = ` -
- -
- `; - return wrapper; -} -function updateStyles(count, div, items = 4) { - if (count < ITEMS_PER_PAGE) { - // adjust block height - const lines = Math.ceil(count / items); - const height = lines * 400 + 20 * (lines - 1); - div.style.height = `${height}px`; - div.style.gridTemplate = `repeat(${lines}, 400px) / repeat(${items}, 1fr)`; - } else { - div.style = ''; - } -} - -function buildPropertySearchResultsButton() { - const wrapper = document.createElement('div'); - wrapper.classList.add('property-search-results-buttons'); - wrapper.innerHTML = ` -
- - Save - -
-
- - list view - -
- `; - return wrapper; -} - -function buildDisclaimer(html) { - const wrapper = document.createElement('div'); - wrapper.classList.add('disclaimer'); - wrapper.innerHTML = ` - -
- ${html} -
- `; - return wrapper; -} - -function buildPagination(currentPage, totalPages) { - // set map view - document.querySelector('body').classList.add('search-map-active'); - const wrapper = document.createElement('div'); - wrapper.setAttribute('name', 'Page'); - wrapper.classList.add('multiple-inputs'); - let options = ''; - let list = ''; - for (let i = 1; i <= totalPages; i += 1) { - options += ``; - } - for (let i = 1; i <= totalPages; i += 1) { - list += `
  • ${i}
  • `; - } - wrapper.innerHTML = ` -
    -
    - -
    - ${`${currentPage} of ${totalPages}`} -
    - -
    -
    -
    - - -
    `; - return wrapper; -} - -export default async function decorate(block) { - block.textContent = ''; - block.append(buildLoader()); - await renderMap(block); - window.dispatchEvent(event); - window.addEventListener('onResultUpdated', () => { - if (getPropertiesCount() > 0) { - document.querySelector('.property-result-map-container').style.display = 'block'; - const propertyResultContent = document.createElement('div'); - propertyResultContent.classList.add('property-result-content'); - const listings = getPropertyDetails(); - const div = document.createElement('div'); - div.classList.add('property-list-cards'); - const currentPage = parseInt(getValueFromStorage('Page'), 10); - const totalPages = Math.ceil(getPropertiesCount() / ITEMS_PER_PAGE); - const disclaimerHtml = getDisclaimer() === '' ? '' : getDisclaimer().Text; - - const disclaimerBlock = buildDisclaimer(disclaimerHtml); - let nextPage; - listings.forEach((listing) => { - div.append(createCard(listing)); - }); - updateStyles(listings.length, div, 2); - propertyResultContent.append(div); - /** add pagination */ - propertyResultContent.append(buildPagination(currentPage, totalPages)); - /** add property search results button */ - propertyResultContent.append(buildPropertySearchResultsButton()); - /** build disclaimer */ - propertyResultContent.append(buildDisclaimer(disclaimerHtml)); - block.prepend(propertyResultContent); - - // update map - window.updatePropertyMap(getAllData(), false); - - document.querySelector('.property-result-map-container').append(disclaimerBlock); - /** update page on select change */ - block.querySelector('[name="Page"] .select-selected').addEventListener('click', () => { - block.querySelector('[name="Page"] ul').classList.toggle('hide'); - }); - block.querySelector('[name="Page"] ul').addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - nextPage = e.target.closest('li').getAttribute('data-value'); - setFilterValue('Page', nextPage); - block.querySelector('[name="Page"] ul').classList.toggle('hide'); - searchProperty(); - }); - /** update page on arrow click */ - block.querySelector('.pagination-arrows .arrow.prev').addEventListener('click', (e) => { - if (!e.target.closest('.arrow').classList.contains('disabled')) { - e.preventDefault(); - e.stopPropagation(); - setFilterValue('Page', currentPage - 1); - searchProperty(); - } - }); - block.querySelector('.pagination-arrows .arrow.next').addEventListener('click', (e) => { - if (!e.target.closest('.arrow').classList.contains('disabled')) { - e.preventDefault(); - e.stopPropagation(); - setFilterValue('Page', currentPage + 1); - searchProperty(); - } - }); - document.querySelector('.map-toggle > a').addEventListener('click', (e) => { - const span = e.target.closest('span'); - if (span.innerText === 'GRID VIEW') { - span.innerText = 'map view'; - document.querySelector('body').classList.remove('search-map-active'); - updateStyles(listings.length, div, 4); - } else { - span.innerText = 'grid view'; - document.querySelector('body').classList.add('search-map-active'); - updateStyles(listings.length, div, 2); - } - }); - block.querySelector('.property-search-results-buttons .map').addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - const span = e.target.closest('span'); - if (span.innerText === 'LIST VIEW') { - span.innerText = 'map view'; - document.querySelector('body').classList.remove('search-map-active'); - updateStyles(listings.length, div, 1); - } else { - span.innerText = 'list view'; - document.querySelector('body').classList.add('search-map-active'); - } - }); - } else { - document.querySelector('.property-result-map-container').style.display = 'none'; - showModal('Your search returned 0 results.\n' - + 'Please modify your search and try again.'); - } - }); -} diff --git a/blocks/property-result-map/Template.js b/blocks/property-result-map/Template.js deleted file mode 100644 index 9c98da94..00000000 --- a/blocks/property-result-map/Template.js +++ /dev/null @@ -1,93 +0,0 @@ -export default class Template { - // eslint-disable-next-line class-methods-use-this - render = (data) => { - const luxuryHTML = data.luxury && data.isCompanyListing - ? `div class="position-absolute top w-100"> -
    - ${data.luxuryLabel} -
    - ` - : ''; - const soldHTML = data.sellingOfficeName ? `
    ${data.mlsStatus} ${data.ClosedDate}
    ` : ''; - const municipalityHTML = data.municipality ? `
    ${data.municipality}
    ` : ''; - const CourtesyOfHr = data.CourtesyOf ? ` -
    ` : ''; - const addMlsFlagHTML = data.addMlsFlag ? `MLS ID: ${data.ListingId} ` : ''; - const courtersyHTML = data.CourtesyOf ? ` - Listing courtesy of: ${data.CourtesyOf} ` : ''; - const sellingOfficeNameHTML = data.sellingOfficeName ? ` - Listing sold by: ${data.sellingOfficeName} ` : ''; - const addMLsFlagHTML = data.addMlsFlag ? ` -
    Listing Provided by: ${data.listAor}
    - ${data.listAor} - ${data.brImageUrl} - -
    - ${data.ddMlsFlag}` : ''; - return `
    - -
    - ${luxuryHTML} -
    -
    -
    -
    ${data.price}

    -
    ${data.altCurrencyPrice || ''}
    -
    - - - - - - -
    -
    - - - - - - - - - -
    -
    -
    - ${soldHTML} -
    - ${data.address || ''}
    - ${data.city || ''}, ${data.stateOrProvince || ''} ${data.postalCode || ''} -
    -
    - ${municipalityHTML} -
    ${data.providers || ''}
    - ${CourtesyOfHr} -
    - ${addMlsFlagHTML} - ${courtersyHTML} - ${sellingOfficeNameHTML} -
    - ${addMLsFlagHTML} -
    -
    -
    -
    `; - }; -} diff --git a/blocks/property-result-map/map-delayed.js b/blocks/property-result-map/map-delayed.js deleted file mode 100644 index f9c4ab81..00000000 --- a/blocks/property-result-map/map-delayed.js +++ /dev/null @@ -1,1069 +0,0 @@ -/* global google */ -/* eslint-disable no-param-reassign, no-shadow, prefer-rest-params */ - -import { fetchPlaceholders } from '../../scripts/aem.js'; -import { removeFilterValue, searchProperty, setFilterValue } from '../property-search-bar/filter-processor.js'; -import Template from './Template.js'; -import SearchParameters from '../../scripts/apis/creg/SearchParameters.js'; -import { propertySearch } from '../../scripts/apis/creg/creg.js'; - -const Nr = [{ - featureType: 'administrative', - elementType: 'labels.text.fill', - stylers: [{ - color: '#444444', - }], -}, { - featureType: 'administrative.locality', - elementType: 'labels.text.fill', - stylers: [{ - saturation: '-42', - }, { - lightness: '-53', - }, { - gamma: '2.98', - }], -}, { - featureType: 'administrative.neighborhood', - elementType: 'labels.text.fill', - stylers: [{ - saturation: '1', - }, { - lightness: '31', - }, { - weight: '1', - }], -}, { - featureType: 'administrative.neighborhood', - elementType: 'labels.text.stroke', - stylers: [{ - visibility: 'off', - }], -}, { - featureType: 'administrative.land_parcel', - elementType: 'labels.text.fill', - stylers: [{ - lightness: '12', - }], -}, { - featureType: 'landscape', - elementType: 'all', - stylers: [{ - saturation: '67', - }], -}, { - featureType: 'landscape.man_made', - elementType: 'geometry.fill', - stylers: [{ - visibility: 'on', - }, { - color: '#ececec', - }], -}, { - featureType: 'landscape.natural', - elementType: 'geometry.fill', - stylers: [{ - visibility: 'on', - }], -}, { - featureType: 'landscape.natural.landcover', - elementType: 'geometry.fill', - stylers: [{ - visibility: 'on', - }, { - color: '#ffffff', - }, { - saturation: '-2', - }, { - gamma: '7.94', - }], -}, { - featureType: 'landscape.natural.terrain', - elementType: 'geometry', - stylers: [{ - visibility: 'on', - }, { - saturation: '94', - }, { - lightness: '-30', - }, { - gamma: '8.59', - }, { - weight: '5.38', - }], -}, { - featureType: 'poi', - elementType: 'all', - stylers: [{ - visibility: 'off', - }], -}, { - featureType: 'poi.park', - elementType: 'geometry', - stylers: [{ - saturation: '-26', - }, { - lightness: '20', - }, { - weight: '1', - }, { - gamma: '1', - }], -}, { - featureType: 'poi.park', - elementType: 'geometry.fill', - stylers: [{ - visibility: 'on', - }], -}, { - featureType: 'road', - elementType: 'all', - stylers: [{ - saturation: -100, - }, { - lightness: 45, - }], -}, { - featureType: 'road', - elementType: 'geometry.fill', - stylers: [{ - visibility: 'on', - }, { - color: '#fafafa', - }], -}, { - featureType: 'road', - elementType: 'geometry.stroke', - stylers: [{ - visibility: 'off', - }], -}, { - featureType: 'road', - elementType: 'labels.text.fill', - stylers: [{ - gamma: '0.95', - }, { - lightness: '3', - }], -}, { - featureType: 'road', - elementType: 'labels.text.stroke', - stylers: [{ - visibility: 'off', - }], -}, { - featureType: 'road.highway', - elementType: 'all', - stylers: [{ - visibility: 'simplified', - }], -}, { - featureType: 'road.highway', - elementType: 'geometry', - stylers: [{ - lightness: '100', - }, { - gamma: '5.22', - }], -}, { - featureType: 'road.highway', - elementType: 'geometry.stroke', - stylers: [{ - visibility: 'on', - }], -}, { - featureType: 'road.arterial', - elementType: 'labels.icon', - stylers: [{ - visibility: 'off', - }], -}, { - featureType: 'transit', - elementType: 'all', - stylers: [{ - visibility: 'off', - }], -}, { - featureType: 'water', - elementType: 'all', - stylers: [{ - color: '#b3dced', - }, { - visibility: 'on', - }], -}, { - featureType: 'water', - elementType: 'labels.text.fill', - stylers: [{ - visibility: 'on', - }, { - color: '#ffffff', - }], -}, { - featureType: 'water', - elementType: 'labels.text.stroke', - stylers: [{ - visibility: 'off', - }, { - color: '#e6e6e6', - }], -}]; - -let rd = []; -let lg = []; -let uf = []; -let Zd = !1; -const Bj = []; -const Cj = []; -let map; -// eslint-disable-next-line no-unused-vars -let hi = !1; -const Hh = []; -const rn = !0; - -function Yd(a) { - this.baseMarker = a; - const c = a.getPosition().lat(); - const f = a.getPosition().lng(); - this.lat = c; - this.lng = f; - this.pos = new google.maps.LatLng(c, f); - this.icon = a.icon; - this.propId = a.propId; -} - -const bA = (d, h, k, m) => { - d.forEach((d) => { - h.push(d); - d = new Yd(d); - d.setMap(m); - k.push(d); - }); -}; - -function ln(a) { - google.maps.event.addListener(a, 'domready', () => { - uf.push(a); - }); -} -// load Listing info by id -async function yj(listingId) { - // @todo generate dynamicly from result response MLSFlag, AddMlsFlag, OfficeCode - const params = new SearchParameters('ListingId'); - params.ListingId = listingId; - params.MLSFlag = 'false'; - params.AddMlsFlag = 'false'; - params.OfficeCode = 'MA312'; - params.SearchType = 'ListingId'; - return propertySearch(params); -} - -function nn(a) { - return a && a.smallPhotos && a.smallPhotos.length > 0 ? a.smallPhotos[0].mediaUrl : ''; -} -function zj(a, c, f) { - const path = f || '/property/detail'; - const d = nn(a); - const h = a.ListPriceUS; - const q = a.municipality; - const m = a.addMlsFlag; - const p = a.listAor; - const k = a.mlsLogo; - const n = a.mlsStatus; - const B = a.ClosedDate; - const t = a.ListingId; - const y = a.CourtesyOf; - const A = a.sellingOfficeName; - const H = a.ApplicationType; - const J = a.listPriceAlternateCurrency; - const I = a.brImageUrl; - const P = a.StreetName; - const u = a.City; - const v = a.PostalCode; - const gb = a.PropId; - const ea = a.StateOrProvince; - let ka = a.PdpPath; - const Uh = a.luxury; - const E = a.isCompanyListing; - a = a.originatingSystemName === '' || undefined === a.originatingSystemName || a.originatingSystemName === null ? ' ' : `Listing Provided by ${a.originatingSystemName}`; - c = c || ka.split('/'); - ka = `${path}/${c[4]}/${c[5]}`; - return { - propertyImage: d, - propertyPrice: h, - municipality: q, - addMlsFlag: m, - listAor: p, - mlsLogo: k, - mlsStatus: n, - ClosedDate: B, - ListingId: t, - CourtesyOf: y, - sellingOfficeName: A, - ApplicationType: H, - propertyAltCurrencyPrice: J, - brImageUrl: I, - propertyAddress: P, - propertyLinkUrl: ka, - propertyProviders: a, - propertyData: { - city: u, - zipCode: v, - id: gb, - stateOrProvince: ea, - luxury: Uh, - isCompanyListing: E, - }, - }; -} -function Yt(a) { - a = a.listingPins; - return (undefined === a ? [] : a).reduce((a, f) => { - const c = `${f.lat}${f.lon}`; - // eslint-disable-next-line no-unused-expressions - Array.isArray(a[c]) || (a[c] = []); - a[c].push(f); - return a; - }, {}); -} - -const U = { - isXs() { - return window.innerWidth <= 575; - }, - isSm() { - const a = window.innerWidth; - return a >= 576 && a <= 599; - }, - isMd() { - const a = window.innerWidth; - return a >= 600 && a <= 991; - }, - isLg() { - const a = window.innerWidth; - return a >= 992 && a <= 1199; - }, - isXl() { - return window.innerWidth >= 1200; - }, -}; - -function on(a) { - const c = []; - a.getListingPins().forEach((a) => { - c.push(yj(a.listingKey, a.officeCode)); - }); - return c; -} -function Xt(a) { - on(a); -} - -function $t() { - return rn; -} - -function vj(a, c) { - let f = '/icons/maps/map-marker-standard.png'; - let d = 50; - let h = 25; - if (c === 'cluster') { - f = '/icons/maps/map-clusterer-blue.png'; - h = 30; - d = 30; - } - return (q) => { - let m = q.labelText; - let p = undefined === m ? '' : m; - m = q.groupCount; - const k = q.listingKey; - q = new google.maps.LatLng(q.latitude, q.longitude); - let n = 0; - // eslint-disable-next-line no-unused-expressions - c === 'cluster' && (n = (Math.round(2 * Math.log10(m)) / 2) * 6); - n = { - url: f, - scaledSize: new google.maps.Size(d + n, h + n), - }; - p = new google.maps.Marker({ - position: q, - icon: n, - map: a, - anchor: new google.maps.Point(0, 0), - label: { - fontFamily: 'var(--font-family-primary)', - fontWeight: 'var(--font-weight-semibold:)', - fontSize: '12px', - text: p || '', - color: 'var(--white)', - labelAnchor: new google.maps.Point(30, 0), - }, - labelClass: 'no-class', - width: 50, - height: 50, - }); - p.groupCount = m; - p.listingKey = k; - return p; - }; -} - -function Jd() { - uf.forEach((a) => { - const c = a.anchor; - // eslint-disable-next-line no-unused-expressions - c && c.setOptions({ - zIndex: c.bhhsInitialZIndex, - }); - a.close(); - }); - uf = []; - Zd = !0; - document.querySelector('.mobile-info-window').classList.remove('is-active'); - document.querySelector('.mobile-cluster-info-window').innerHTML = null; -} - -function au(a) { - let c = a.groupOfListingPins; - const f = undefined === c ? [] : c; - const d = a.propertiesMap; - const h = a.isAgentPage; - const q = a.agentHomePath; - a = vj(d, 'cluster'); - c = []; - const m = f[0]; - const p = f.length; - const k = m.lat; - const n = m.lon; - const B = a({ - latitude: k, - longitude: n, - labelText: `${p}`, - visible: !0, - groupCount: p, - }); - c.push(B); - B.setZIndex(0); - B.bhhsInitialZIndex = B.getZIndex(); - const t = { - getListingPins() { - return [].concat(f); - }, - getCenter() { - return new google.maps.LatLng(k, n); - }, - }; - // eslint-disable-next-line no-unused-expressions - U.isXs() ? B.addListener('click', () => { - Jd(); - Xt(t, h, q); - }) : (B.addListener('mouseover', () => { - // eslint-disable-next-line no-unused-expressions - $t && Zd && (Jd(), - Zd = !1 - // Wt(t, d, B, h, q) - ); - }) - ); - return c; -} - -function Pt() { - Yd.prototype = new google.maps.OverlayView(); - // eslint-disable-next-line func-names - Yd.prototype.onRemove = function () {}; - // eslint-disable-next-line func-names - Yd.prototype.onAdd = function () { - const a = document.createElement('DIV'); - a.style.position = 'absolute'; - a.className = 'RevealMarker'; - const c = this.icon.scaledSize.height; - const f = this.icon.scaledSize.width; - let d = '50%'; - let h = '50%'; - // eslint-disable-next-line no-unused-expressions - this.baseMarker.icon.labelOrigin && (h = `${this.baseMarker.icon.labelOrigin.x}px`, - d = `${this.baseMarker.icon.labelOrigin.y}px`); - a.innerHTML = `\x3cdiv class\x3d"reveal-marker"\x3e\x3cimg src\x3d"${this.icon.url.replace('map-marker', 'map-reveal-marker')}" style\x3d"width:${f}px;height:${c}px" alt\x3d"Marker image"\x3e\x3cspan class\x3d"reveal-marker__text" style\x3d"top:${d};left: ${h}"\x3e${this.baseMarker.label.text}\x3c/span\x3e\x3c/div\x3e`; - a.style.visibility = 'hidden'; - this.getPanes().floatPane.appendChild(a); - this.div = a; - }; - // eslint-disable-next-line func-names - Yd.prototype.draw = function () { - this.getProjection(); - const a = this.icon.scaledSize.width; - this.div.style.top = `${-1 * this.icon.scaledSize.height}px`; - this.div.style.left = `${-1 * Math.round(a / 2)}px`; - }; - // eslint-disable-next-line func-names - Yd.prototype.hide = function () { - // eslint-disable-next-line no-unused-expressions - this.div && (this.div.style.visibility = 'hidden'); - }; - // eslint-disable-next-line func-names - Yd.prototype.show = function () { - // eslint-disable-next-line no-unused-expressions - this.div && (this.div.style.visibility = 'visible'); - }; - // eslint-disable-next-line func-names - Yd.prototype.getPosition = function () { - return this.baseMarker.getPosition(); - }; -} - -function bu() { - for (let a = 0; a < rd.length; a += 1) { - rd[a].setMap(null); - rd[a] = null; - } - rd = []; - lg.forEach((a) => { - a.clearMarkers(); - }); - lg = []; -} - -function sn() { - if (Bj.length > 0) for (let a = 0; a < Bj.length; a += 1) Bj[a].close(); - if (Cj.length > 0) for (let a = 0; a < Cj.length; a += 1) Cj[a].close(); - document.querySelector('.mobile-cluster-info-window').style.display = 'none'; - document.querySelector('.mobile-info-window').classList.remove('is-active'); -} - -function cu(a) { - const c = new google.maps.LatLngBounds(); - a.forEach((a) => { - c.extend(a.getPosition()); - }); - // eslint-disable-next-line no-unused-expressions - a.length > 0 && (map.setCenter(c.getCenter()), - map.fitBounds(c)); -} - -function Zt(a) { - a.addListener('click', () => { - Jd(); - }); -} - -function Qt() { - const a = arguments.length > 0 && undefined !== arguments[0] ? arguments[0] : []; - const c = vj(arguments[1], 'cluster'); - const f = []; - a.forEach((a, h) => { - const d = a.count; - h = a.neLat; - const m = a.neLon; - const p = a.swLat; - const k = a.swLon; - a = c({ - latitude: a.centerLat, - longitude: a.centerLon, - labelText: `${d}`, - visible: !0, - groupCount: d, - }); - a.neLat = h; - a.neLon = m; - a.swLat = p; - a.swLon = k; - f.push(a); - }); - return f; -} - -function Rt(a) { - a.filter((a) => a.visible).forEach((a) => { - a.addListener('click', () => { - const event = new CustomEvent( - 'search-by-cluster-click', - { - detail: { - northEastLatitude: a.neLat, - northEastLongitude: a.neLon, - southWestLatitude: a.swLat, - southWestLongitude: a.swLon, - }, - }, - ); - window.dispatchEvent(event); - const c = new google.maps.LatLngBounds(); - c.extend(new google.maps.LatLng(a.neLat, a.neLon)); - c.extend(new google.maps.LatLng(a.swLat, a.swLon)); - a.map.panTo(c.getCenter()); - }); - }); -} - -function commaSeparatedPriceToFloat(a) { - a = (`${a}`).replace('$', ''); - a = a.replace(/,/g, ''); - a = parseFloat(a); - return Number.isNaN(a) ? 0 : a; -} - -function nFormatter(a, c) { - const f = [{ - value: 1, - symbol: '', - }, { - value: 1E3, - symbol: 'k', - }, { - value: 1E6, - symbol: 'M', - }, { - value: 1E9, - symbol: 'G', - }, { - value: 1E12, - symbol: 'T', - }, { - value: 1E15, - symbol: 'P', - }, { - value: 1E18, - symbol: 'E', - }]; let - d; - for (d = f.length - 1; d > 0 && !(a >= f[d].value); d -= 1) ; - return (a / f[d].value).toFixed(c).replace(/\.0+$|(\.[0-9]*[1-9])0+$/, '$1') + f[d].symbol; -} - -function Tt(a) { - a = commaSeparatedPriceToFloat(a); - return `$${nFormatter(a, 1)}`; -} - -function Aj(a) { - let c = a.propertyData; - let f = undefined === c ? { - city: '', - zipCode: '', - id: '', - stateOrProvince: '', - luxury: !1, - isCompanyListing: !1, - } : c; - c = a.propertyImage; - const d = a.propertyPrice; - const h = a.municipality; - const q = a.addMlsFlag; - const m = a.listAor; - const p = a.mlsLogo; - const k = a.mlsStatus; - const n = a.ClosedDate; - const B = a.ListingId; - const t = a.CourtesyOf; - const y = a.sellingOfficeName; - const A = a.ApplicationType; - const H = a.propertyAltCurrencyPrice; - const J = a.brImageUrl; - const I = a.propertyAddress; - const P = a.propertyPdpPath; - f = { - city: f.city, - state: f.stateOrProvince, - postalCode: f.zipCode, - listingKey: f.id, - luxury: f.luxury, - isCompanyListing: f.isCompanyListing, - }; - f = undefined === f ? { - city: '', - state: '', - postalCode: '', - listingKey: '', - luxury: !1, - isCompanyListing: !1, - } : f; - a = a.propertyProviders; - // @todo prepare content for info window - const cont = new Template().render({ - image: c, - price: d, - municipality: h, - addMlsFlag: q, - listAor: m, - mlsLogo: p, - mlsStatus: k, - ClosedDate: n, - ListingId: B, - CourtesyOf: t, - sellingOfficeName: y, - ApplicationType: A, - altCurrencyPrice: H, - brImageUrl: J, - address: I, - city: f.city, - stateOrProvince: f.state, - postalCode: f.postalCode, - propertyId: f.listingKey, - linkUrl: P, - luxury: f.luxury, - luxuryLabel: 'luxury collection', - isCompanyListing: f.isCompanyListing, - providers: a, - }); - return new google.maps.InfoWindow({ - content: cont, - }); -} - -function mn(a) { - google.maps.event.addListener(a, 'domready', () => { - uf.push(a); - }); -} - -function jn(a) { - return { - leadParam: a.propertyLeadParam, - StreetName: a.propertyAddress, - PropId: a.propertyId, - ApplicationType: a.propertyApplicationType, - NotificationId: a.NotificationId, - originatingSystemName: a.originatingSystemName, - }; -} - -function St() { - const a = arguments.length > 0 && undefined !== arguments[0] ? arguments[0] : []; - const c = arguments[1]; - const f = arguments[2]; - const d = arguments[3]; - const h = vj(c); - const q = []; - a.forEach((a, p) => { - const m = a.lat; - const n = a.lon; - const B = Tt(a.price); - const t = a.listingKey; - const y = a.officeCode; - a = h({ - latitude: m, - longitude: n, - labelText: `${B}`, - visible: !0, - groupCount: 1, - listingKey: t, - }); - // eslint-disable-next-line no-unused-expressions - U.isXs() ? (a.addListener('click', () => { - Jd(); - }), - a.bhhsInitialZIndex = a.getZIndex(), - // eslint-disable-next-line func-names - a.addListener('click', function () { - this.setOptions({ - zIndex: 1000002, - }); - - // eslint-disable-next-line func-names - })) : (a.addListener('mouseover', function () { - if (Zd) { - Jd(); - Zd = !1; - const a = this; - if (this.propertyPdpPath) { - let h = a.propertyImage; - const m = a.propertyPrice; - const q = a.municipality; - const p = a.addMlsFlag; - const n = a.listAor; - const k = a.mlsLogo; - const x = a.mlsStatus; - const B = a.ClosedDate; - const u = a.ListingId; - const v = a.CourtesyOf; - const E = a.sellingOfficeName; - const G = a.ApplicationType; - const X = a.propertyAltCurrencyPrice; - const Jb = a.brImageUrl; - const Ab = a.propertyAddress; - const Ba = a.propertyProviders || a.originatingSystemName; - const da = a.propertyId; - const Id = a.propertyCity; - const Ma = a.propertyZipcode; - const Wh = a.propertyState; - const aa = a.propertyPdpPath; - const qd = a.propertyLuxury; - const L = a.propertyIsCompanyListing; - const O = jn(a); - h = Aj({ - propertyImage: h, - propertyPrice: m, - municipality: q, - addMlsFlag: p, - listAor: n, - mlsLogo: k, - mlsStatus: x, - ClosedDate: B, - ListingId: u, - CourtesyOf: v, - sellingOfficeName: E, - ApplicationType: G, - propertyAltCurrencyPrice: X, - brImageUrl: Jb, - propertyAddress: Ab, - propertyPdpPath: aa, - propertyProviders: Ba, - propertyData: { - city: Id, - zipCode: Ma, - id: da, - stateOrProvince: Wh, - luxury: qd, - isCompanyListing: L, - }, - }); - h.open(c, a); - ln(h, O); - Zd = !0; - } else { - const F = Aj({ - propertyImage: '', - propertyPrice: 'loading...', - propertyAltCurrencyPrice: '', - brImageUrl: '', - propertyAddress: '-', - municipality: '', - addMlsFlag: '', - listAor: '', - mlsLogo: '', - mlsStatus: '', - ClosedDate: '', - ListingId: '', - CourtesyOf: '', - sellingOfficeName: '', - ApplicationType: '', - propertyPdpPath: '', - propertyProviders: '-', - propertyData: {}, - }); - mn(F); - F.open(c, a); - // @todo fix property info window with link to property detal page - - yj(t, y).then((h) => { - F.close(); - Zd = !0; - let m = zj(h, f, d); - m = Aj({ - propertyImage: m.propertyImage, - propertyPrice: m.propertyPrice, - municipality: m.municipality, - addMlsFlag: m.addMlsFlag, - listAor: m.listAor, - mlsLogo: m.mlsLogo, - mlsStatus: m.mlsStatus, - ClosedDate: m.ClosedDate, - ListingId: m.ListingId, - CourtesyOf: m.CourtesyOf, - sellingOfficeName: m.sellingOfficeName, - ApplicationType: m.ApplicationType, - propertyAltCurrencyPrice: m.propertyAltCurrencyPrice, - brImageUrl: m.brImageUrl, - propertyAddress: m.propertyAddress, - propertyProviders: m.propertyProviders, - propertyPdpPath: m.propertyLinkUrl, - propertyData: { - city: m.propertyData.city, - zipCode: m.propertyData.zipCode, - id: m.propertyData.id, - stateOrProvince: m.propertyData.stateOrProvince, - luxury: m.propertyData.luxury, - isCompanyListing: m.propertyData.isCompanyListing, - }, - }); - m.open(c, a); - ln(m, h); - }); - } - } - }), - // eslint-disable-next-line func-names - a.addListener('mouseover', function () { - this.setOptions({ - zIndex: 1000002, - }); - })); - a.setZIndex(200 + p); - a.bhhsInitialZIndex = a.getZIndex(); - q.push(a); - }); - return q; -} - -function aA(d, h, k, m, p) { - h = h || []; - k = k || []; - let q = []; - Zt(d); - if (h.length > 0) { - k = Qt(h, d); - Rt(k); - q = q.concat(k); - } else if (k.length > 0) { - const n = Yt({ - listingPins: k, - }); - const B = []; - Object.keys(n).forEach((h) => { - h = n[h]; - // eslint-disable-next-line no-unused-expressions - h.length > 1 ? (h = au({ - groupOfListingPins: h, - propertiesMap: d, - isAgentPage: m, - agentHomePath: p, - }), - q = q.concat(h)) : B.push(h[0]); - }); - k = St(B, d, m, p); - q = q.concat(k); - } - return { - markers: q, - markerClusters: [], - }; -} - -// @todo move to search class -function createMapSearchGeoJsonParameter(coordinates) { - return { - type: 'FeatureCollection', - features: [{ - type: 'Feature', - properties: {}, - geometry: { - type: 'Polygon', - coordinates: [coordinates], - }, - }], - }; -} - -async function initMap() { - Pt(); - const mapDiv = document.querySelector('.property-result-map'); - const { Map } = await google.maps.importLibrary('maps'); - map = new Map(mapDiv, { - zoom: 10, - maxZoom: 18, - center: new google.maps.LatLng(41.24216, -96.20799), - mapTypeId: 'roadmap', - clickableIcons: false, - gestureHandling: 'greedy', - styles: Nr, - visualRefresh: true, - disableDefaultUI: true, - }); - window.mapInitialized = !0; - window.renderPropertyMap = (d) => { - const h = arguments.length > 1 && undefined !== arguments[1] ? arguments[1] : !0; - // eslint-disable-next-line no-new - new google.maps.InfoWindow({ - pixelOffset: new google.maps.Size(0, 0), - }); - Jd(); - sn(); - // eslint-disable-next-line no-unused-expressions - d && (Promise.resolve(1).then(() => { - // update map center - // eslint-disable-next-line no-unused-expressions,no-mixed-operators - !h && window.boundsInitialized || cu(rd); - })); - document.querySelector('.map-style-hybrid').addEventListener('click', () => { - document.querySelector('.map-style-hybrid').classList.toggle('activated'); - if (document.querySelector('.map-style-hybrid').classList.contains('activated')) { - map.setMapTypeId(google.maps.MapTypeId.HYBRID); - map.setOptions({ - styles: [], - }); - } else { - map.setOptions({ styles: Nr }); - map.setMapTypeId(google.maps.MapTypeId.ROADMAP); - } - }); - document.querySelector('.map-zoom-in').addEventListener('click', () => { - hi = !0; - map.setZoom(map.getZoom() + 1); - }); - document.querySelector('.map-zoom-out').addEventListener('click', () => { - hi = !0; - map.setZoom(map.getZoom() - 1); - }); - }; - - window.updatePropertyMap = (d, h, k, m) => { - // eslint-disable-next-line no-unused-expressions - window.mapInitialized ? (clearTimeout(window.queuedRenderPropertyMap), - bu(), - window.renderPropertyMap(d, h), - h = aA(map, d && d.listingClusters && d.listingClusters.length > 0 - ? d.listingClusters : [], d && d.listingPins && d.listingPins.length > 0 - ? d.listingPins - : [], k, m), - k = h.markerClusters, - bA(h.markers, rd, Hh, map), - lg = [].concat(k)) : window.queuedRenderPropertyMap = setTimeout(() => { - window.updatePropertyMap(d); - }, 200); - }; - window.addEventListener('search-by-cluster-click', (e) => { - // set Search params - setFilterValue('NorthEastLatitude', e.detail.northEastLatitude); - setFilterValue('NorthEastLongitude', e.detail.northEastLongitude); - setFilterValue('SouthWestLatitude', e.detail.southWestLatitude); - setFilterValue('SouthWestLongitude', e.detail.southWestLongitude); - setFilterValue('SearchType', 'Map'); - let m = []; - const p = [e.detail.southWestLongitude, e.detail.northEastLatitude]; - const q = [e.detail.northEastLongitude, e.detail.northEastLatitude]; - const x = [e.detail.northEastLongitude, e.detail.southWestLatitude]; - const T = [e.detail.southWestLongitude, e.detail.southWestLatitude]; - m.push(p); - m.push(T); - m.push(x); - m.push(q); - m.push(p); - m = createMapSearchGeoJsonParameter(m); - m = JSON.stringify(m); - removeFilterValue('MapSearchType'); - setFilterValue('SearchParameter', m); - searchProperty(); - }); - - // add custom events handling -} - -function loadJS(src) { - const script = document.createElement('script'); - script.type = 'text/javascript'; - script.async = true; - script.defer = true; - script.innerHTML = ` - (()=>{ - let script = document.createElement('script'); - script.src = '${src}'; - document.head.append(script); - })(); - `; - document.head.append(script); -} - -async function initGoogleMapsAPI() { - const placeholders = await fetchPlaceholders(); - const CALLBACK_FN = 'initMap'; - window[CALLBACK_FN] = initMap; - const { mapsApiKey } = placeholders; - - const clusterSrc = 'https://unpkg.com/@googlemaps/markerclusterer/dist/index.min.js'; - loadJS(clusterSrc); - const src = `https://maps.googleapis.com/maps/api/js?key=${mapsApiKey}&libraries=places&callback=${CALLBACK_FN}`; - loadJS(src); -} - -initGoogleMapsAPI(); diff --git a/blocks/property-result-map/map.css b/blocks/property-result-map/map.css deleted file mode 100644 index 215c9c17..00000000 --- a/blocks/property-result-map/map.css +++ /dev/null @@ -1,449 +0,0 @@ -.property-search-template.search-map-active .property-result-listing.block .d-none { - display: none; -} - -.property-search-template.search-map-active .info-window div { - padding: 3px; -} - -.property-search-template.search-map-active .property-result-listing.block > div:last-of-type { - position: fixed; - width: 50%; - left: 50%; - top: calc(var(--nav-height) + 50px + 120px); - - /* todo need to figureout on how to do this dynamicly */ - height: 600px; - max-width: calc(1400px/2 - 30px); -} - -.property-search-template.search-map-active .info-window { - width: 178px; - pointer-events: all; -} - -.property-search-template.search-map-active .info-window a { - color: inherit; - text-decoration: none; -} - -.property-search-template.search-map-active .info-window .info .price { - font-size: var(--body-font-size-m); - font-family: var(--font-family-primary); - font-weight: var(--font-weight-bold); -} - -.property-search-template.search-map-active svg:not(:root) { - overflow: hidden; -} - -.property-search-template.search-map-active .info-window .btn-contact-property svg, -.property-search-template.search-map-active .info-window .btn-save-property svg { - width: 100%; - height: 100%; -} - -.property-search-template.search-map-active .info-window .btn-contact-property .envelope, -.property-search-template.search-map-active .btn-save-property .empty{ - position: absolute; - opacity: 1; -} - -.property-search-template.search-map-active .info-window .btn-contact-property .envelope-dark, -.property-search-template.search-map-active .btn-save-property .empty-dark { - position: absolute; - opacity: 0; -} - -.property-search-template.search-map-active .info-window .btn-save-property .full { - position: absolute; - display: none; -} - -/* stylelint-disable selector-class-pattern */ -.property-search-template.search-map-active .info-window .info .altPrice { - font-size: var(--body-font-size-s); - font-family: var(--font-family-primary); - font-weight: var(--font-weight-light); -} - -.property-search-template.search-map-active .info-window .info .address, -.property-search-template.search-map-active .info-window .info .providers { - font-size: var(--body-font-size-xs); -} - -.property-search-template.search-map-active .cmp-property-tile__extra-info { - display: block; - font-size: var(--body-font-size-xs); - font-family: var(--font-family-primary); - font-weight: var(--font-weight-light); - padding-left: 10px; - opacity: 1; - color: var(--dark-grey); - letter-spacing: 0; - line-height: var(--line-height-xs); -} - -.property-search-template.search-map-active .info-window .arrow { - width: 0; - border-top: 20px solid transparent; - border-left: 20px solid transparent; - border-right: 20px solid transparent; - cursor: pointer; - content: ""; - position: absolute; - bottom: 0; - left: 50%; - transform: translateX(-50%); - transition: .2s bottom opacity ease-out; - opacity: 1; -} - -.property-search-template.search-map-active .info-window .text-danger { - color: #dc3545 !important; -} - -.property-search-template.search-map-active .gm-ui-hover-effect { - display: none !important; -} - -.property-search-template.search-map-active .gm-ui-hover-effect, button[draggable] { - opacity: 0; -} - -.property-search-template hr { - margin-top: 1rem; - margin-bottom: 1rem; - border: 0; - border-top: 1px solid rgb(0 0 0 / 10%); - box-sizing: content-box; - height: 0; - overflow: visible; -} - -.property-search-template.search-map-active .info-window .property-image { - width: 100%; - height: 131px; - background-size: cover; - background-repeat: no-repeat; - position: relative; -} - -.property-search-template.search-map-active .btn-contact-property, -.property-search-template.search-map-active .btn-save-property { - display: block; - position: relative; - width: 18px; - height: 18px; - cursor: pointer; - margin-left: auto; -} - -.property-search-template .d-flex { - display: flex !important; - -} - -.property-search-template .align-items-center { - align-items: center !important; - -} - -.property-search-template .p-0 { - padding: 0 !important; -} - -.property-search-template .mr-2 { - margin-right: 0.5rem !important; -} - -.property-search-template.search-map-active .info-window .info { - background: var(--white); -} - -.property-search-template.search-map-active .info-window::before, -.property-search-template.search-map-active .info-window::after { - content: ""; - bottom: 0; - opacity: 0; - transition: .2s bottom opacity ease-in; -} - - -.property-search-template .property-result-map { - height: 600px; - width: 100%; - display: none; - position: absolute; -} - -.property-search-template.search-map-active .property-result-map { - display: block; - -} - -.property-search-template .map-controls-container { - display: none; -} - - -.property-search-template.search-map-active .map-controls-container { - height: 100%; - width: 100% !important; - padding-left: 0; - padding-right: 0; - max-width: 1350px; - margin: 0 auto; - display: block; -} - -.property-search-template.search-map-active .custom-controls a { - pointer-events: all; - height: 45px; - width: 45px; - background: var(--white); - display: block; - position: relative; - cursor: pointer; -} - -.property-search-template.search-map-active .custom-controls a.map-style-hybrid { - position: relative; - box-shadow: 0 0 3px 2px rgb(0 0 0 / 30%); -} - -.property-search-template.search-map-active .custom-controls a.map-style-hybrid::before { - background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAYAAACM/rhtAAAAAXNSR0IArs4c6QAACOhJREFUWAmlmXlsVFUUhzsz3RfLUgjQ0hYFVBqQRaAUgQCiskgQCIJ/iEpiZDUoi4CBKBFcQmKiUEwUjTFsScENxCgVUEsBC5VV9imUWgUrldKFTjt+v8e8YTrzXlvgJqfn3LPf7dz7po6wu2g9evSIi4qKSo+IiEivr6/vgKt4n7sKp9NZUltb666pqXEfPnz4+p2GcdyuYc+ePVvExsY+gd1Er9fbG5zscDgirfwgvwH/ElCATk5lZeWOwsLCq1a6djyXnSCYn5WV1TY5OXleZGTkp8gmA6VAEoFbM3sboZ8FUoDTwGT4LYCHoK+CNYMvM9PTUlJS4tq0aXO8tLS0El6TzdmkBgok9zyokECzwetI6EFmZxP9duBF+fn5U/bu3XuIvgd5rei8vLzJyBbDaw9vo8fj6SZbYE5MTMzvmZmZz0E32RpNsF+/fq1JbiOBPgZyqqurexD4dYIm4DkbXjbJvB0QBZHDv22QrURnrSA8PDxOtvJBfwtq60hyw4ABA1oF2IeQtgn27dv3XpfLlYvFIGA0wWYfOnSoBDoc52uAE9BLgKbaYhROAmuAcPnA1yzoURykIfjJVSz6ls0yQRkw4m0Ya+9k4XCHac2Ix0A/wjLPg19l8u2wdNB9FfkgZmyUqSef8LPoVymWXZIhCWrK2cxaggdYiq+Ba6ZTH54Pbzf7bmcQ37a7b9++HxH+jM8FgUr0/1MMxSJmjtVyhwcaQGv/rAXaYrge/Jqcsg9zGe1W+ko2E5lm0QsEN0ReSz6K7wBfM4tjwQn4HQ8MQ90BKNYwIBtQhfD78G9omGEYT2VffEYyI5ihH/v06ZNKWXkSRxNw0geVGOgI8Hb6vwGnoC+By7BT2chGTtc7Ax+x0DoAqpNdwX3BI5HVQldCH4TOuXHjxjcFBQUX+vfv/xh7/nt4U1n+z9Exmj9BkkniVjiOwmYUtIkbNKZfgfbALANK0OtGPwk6BlpbxUM/EhxGXwVah6keXEX/MrQOVTLQgv4QYqiAN2is1BpkE0g6g6SvSOhfYpJTUhGMfHkDK18HIxezmYJ8AXsqR7aUoUQCt8JpIjMYD71C6vQXo1dBvxy6bP/+/eWwPSQwEfwFNVEDCmlcjW9yYJ5hP85E+IYUjASZvUToGcBqgv8lQXAjOS2x1u+gT+Yh8D/QAqMxy7L1sj123eQ0/EsCBwnuIgn5uthQGhZ24MCBUgaxGv4McnqfWSw3RoKR7taWOFClt2zMxsMIiq9fvx6yNKYBOk6B2Q/G3MXFDFCgBC0bj4t1+FAleVwKhjMYkzD6lRGcs7S6ycwAnTx27Jj21x012RLrFCBflo1ZO0sueWyZSVJw6skEVsH8VgybpsOUhuPTNvJmswkuH+mA/4AGG6OzDRio3Jzx8fGdCJzEps4PVjT76enpURgkARdM3l3gIsXr3LmzceKt/DB7+dLRW9NJYp0IrNrktlIWLyEhQfUvHvJvO53m8oklHwlMTIydDWfhPDIPdfFenWLVpnJOb6mdAc60HC4GY9QmOz2C1yHz3wJWesyOfLjq6upsl5g4pfgqZ1I6OLg95kK8hdFOsAprcFNAjVbXUgH4T/RCTioy2WYCaloiO532yHSKcwE9NkISxZeqwaMkuthfqFFUIiGjR9mLMiI83bzGRIboianWHJ2bmjgJ8G3yfFgiI4YS1CjKuXrGg/UiDmndu3dvyT4sYlmWsxW2hyj4GBTqLXKMrwl2OhTi0cg2UBOftvs+oUhHcDFcZLCVWgYV3kQu63Z2Tn38OvaP7l7bhkOXwFYBAcsmH5YTYdoRpx1+EhlriZNZOUcngk4nUyEYX7t2TRd+Bfy2wbLb7RNLPipoWjnLxi2iXMKVm1PfrQS/wpHub6kN0+121+D4CpBqp3Mb/DTFO3PmTI2dDbOsw3YZfN7p+6jOgzHGzgC+NmwRjrs2otMsEYPsgqK7MWWWWLnkce0Ze1CnaTOGA+2+C3zOjoK7ZmRk2N4AjQWVTLbEuh84ZqfLAbkP2QBmb7N0jFpF5d6BURlr/4KdIXLVwJS4uDgVdsuGTr3AUggzOjq6IxOhh698WTZO7zTlQk7fS8Gog3p3UbDX0J/Laf7A6k3Ig7UAY9VE/dxxXraMtgV7txXvu0Q2tB6sqgRefAyFX8HDtBx+Gf71c4cHe9nWwbdMkBWU/UxglXIC33pRk/FqLueZBFnqU5Lc30iiHlkxgRdR76ZCd2Ok+ukjGsy2cap0GMsPrdcIJuH16FejfxnZCXQ7gIvF9zsOIOAvo1vNoPRoNVqDa8b8aELhMWbxB4pqGnthLI5VxDV646OJ4ErgAHw9nVRHy5jhSmZoLTwvlWE6dCz8VkAyul1Iuh/0KGg9TFRi9DLPQf8bfnEoavKjCWU1B6PdCB6Mo10YjwLqoPUNrFtCP6ttZQBjrW4UBvQlOrpJnpKzwOa7Qb5CPA7+PfjVoHW/q7BvA4bC202yU6CNaw58a4nVoUkwHcVcQD/+LKJIf3TkyJF/DSl/GEA+SS6E/A7wO/LJMWuwKD628SCQDbnv/dbHXI8vzfCL2KwEFxJrOriBz5AXBw50gsaT3B8oj+PU6oPK31jy93A4mO0w3M9sgmD5RqAyCNt3A1VZCfkeR6wTHJzxgRNh6oUkKIG+TTAYTSLRzFYeyYw0DVhaLYd+xljFDNg+Ok196eBjFUns4Wtvu8mXTw5QHn6iSHQ0X4jnTVkgtkxQCkoSp8MY9W6cbCPQh4BqoE7rDOABYAXQVFuJfRcGLJs6+SC51fJJ/ydiDLdLTo4bfXkUFxdXATn8KlrELMxCf3ZqamoiwfYx+qMEWYasHp1f5Kxjx47at176m9QnkSXYvcIgp8M+m5aWNh/2J/A605/DjC5VDOnaNcsdbaXcq1evNtwEOkAvIdfm3kOQLvTToTdpfxF4OTy1pfAXAvqcdYNVjgYDZfTX8iNmNr8TqjY22ZqdoOnJ/BGdQBMI3BucAra8n5HpO1gf6gXgLXfyI/ptJ2gmKmz+G4KZ0zdzByBefBKqAEog3VVVVUV382+I/wHjBJq6X/x9ywAAAABJRU5ErkJggg=="); - content: " "; - height: 22px; - width: 22px; - top: 5px; - left: 50%; - transform: translateX(-50%); - position: absolute; - background-repeat: no-repeat; - background-size: contain; -} - -.property-search-template.search-map-active .custom-controls a.map-style-hybrid::after { - content: attr(data-text); - position: absolute; - text-align: center; - width: 100%; - bottom: 8px; - line-height: 0; - font-size: var(--body-font-size-xs); - color: var(--body-color); -} - -.property-search-template.search-map-active a.map-draw-complete { - display: flex; - margin-top: 15px; - box-shadow: 0 0 3px 2px rgb(0 0 0 / 30%); -} - -.property-search-template.search-map-active .custom-controls a.map-draw { - margin-top: 15px; - box-shadow: 0 0 3px 2px rgb(0 0 0 / 30%); - - /* @todo change visibility for draw functionality https://github.com/hlxsites/hsf-commonmoves/issues/122 */ - visibility: hidden; -} - -.property-search-template.search-map-active .custom-controls a.map-draw::before { - background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHdpZHRoPSIxN3B4IiBoZWlnaHQ9IjE3cHgiIHZpZXdCb3g9IjAgMCAxNyAxNyIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIj4gICAgICAgIDx0aXRsZT5Db21iaW5lZCBTaGFwZTwvdGl0bGU+ICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPiAgICA8ZyBpZD0iRG9jdW1lbnQtU3ltYm9scyIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+ICAgICAgICA8ZyBpZD0iVUkvUGVuY2lsIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTMuMDAwMDAwLCAtOC4wMDAwMDApIiBmaWxsPSIjM0EzQTNBIj4gICAgICAgICAgICA8cGF0aCBkPSJNMTUuNzY0MTY1LDggTDE3LjYwNzQ2MTksOS44NDI4MDc5IEwxNC44NDIyMzQzLDEyLjYwNzk5OTMgTDEzLDEwLjc2NDE2MiBMMTUuNzY0MTY1LDggWiBNMzAsMjUgTDI2LjkwODAwNDIsMjQuNjcyMTczNSBMMjkuNjcyNjY3MywyMS45MDc5NzgzIEwzMCwyNSBaIE0yOC43NTA3MywyMC45ODU1OTE1IEwyNS45ODYwNjY5LDIzLjc1MDc0OTYgTDE1Ljc2NDE3NSwxMy41MjkzODk5IEwxOC41MjkzMDI5LDEwLjc2NDE2NTQgTDI4Ljc1MDczLDIwLjk4NTU5MTUgWiIgaWQ9IkNvbWJpbmVkLVNoYXBlIj48L3BhdGg+ICAgICAgICA8L2c+ICAgIDwvZz48L3N2Zz4="); - content: " "; - height: 17px; - width: 17px; - top: 8px; - left: 13px; - position: absolute; - background-repeat: no-repeat; - background-size: contain; -} - -.property-search-template.search-map-active .custom-controls a.map-draw::after { - content: attr(data-text); - position: absolute; - text-align: center; - width: 100%; - bottom: 8px; - line-height: 0; - font-size: var(--body-font-size-xs); - color: var(--body-color); -} - -.property-search-template.search-map-active .custom-controls { - position: absolute; - left: auto; - right: 15px; - pointer-events: none; - bottom: 15px; -} - -.property-search-template.search-map-active .custom-controls .zoom-controls { - box-shadow: 0 0 3px 2px rgb(0 0 0 / 30%); - bottom: 0; - right: 0; - z-index: 0; - pointer-events: all; -} - -.property-search-template.search-map-active .custom-controls a.map-zoom-in::before { - content: " "; - position: absolute; - display: block; - background-color: var(--body-color); - width: 1px; - margin-left: 0; - left: 50%; - top: calc(50% - 8px); - z-index: 9; - height: 16px; -} - -.property-search-template.search-map-active .custom-controls a.map-zoom-in::after { - content: " "; - position: absolute; - display: block; - background-color: var(--body-color); - height: 1px; - top: calc(50% - 1px); - left: calc(50% - 8px); - width: 16px; - z-index: 9; -} - -.property-search-template.search-map-active .custom-controls a.map-zoom-out::after { - content: " "; - position: absolute; - display: block; - background-color: var(--body-color); - height: 1px; - top: calc(50% - 1px); - left: calc(50% - 8px); - width: 16px; - z-index: 9; -} - -.property-search-template.search-map-active .map-draw-tooltip { - position: absolute; - width: 100%; - height: 30px; - line-height: var(--line-height-xl); - font-size: var(--body-font-size-xs); - background: var(--light-grey); - text-align: center; - color: var(--body-color); - font-family: var(--font-family-primary); - z-index: 1; -} - -.property-search-template.search-map-active .map-search-wrapper { - position: absolute; - top: 0; - left: 0; - width: 100%; - overflow: hidden; -} - -.property-search-template.search-map-active .map-search-wrapper a.map-search-toggle { - box-shadow: 0 0 3px 2px rgb(0 0 0 / 30%); - width: 100%; - text-align: center; - height: 30px; - font-size: 12px; - line-height: 30px; - background: #fff; - margin-bottom: 10px; - cursor: pointer; - display: none; -} - -/* hide google map keyboard shortcuts text and google logo */ -.property-search-template.search-map-active .gm-style-cc { - display: none; -} - -.property-search-template.search-map-active a[href^="http://maps.google.com/maps"] { - display: none !important -} - -.property-search-template.search-map-active a[href^="https://maps.google.com/maps"] { - display: none !important -} - -/* end */ - -@media (min-width: 600px) { - .property-search-template.search-map-active .custom-controls { - bottom: 16vw; - } - - .property-search-template.search-map-active .property-result-listing.block > div:last-of-type { - width: 100%; - left: 0; - top: calc(var(--nav-height) + 50px + 120px); - height: 600px; - - } -} - -.property-result-listing.block .property-result-map-container .disclaimer { - display: none; -} - -@media (max-width: 899px) { - .property-search-template.search-map-active .property-result-listing.block .property-result-map-container .disclaimer { - display: block; - } -} - -@media (min-width: 900px) { - .property-search-template.search-map-active .property-result-listing.block > div:last-of-type { - position: fixed; - width: 100%; - left: 50%; - top: calc(var(--nav-height) + 50px + 120px); - - /* todo need to figureout on how to do this dynamicly */ - height: 600px; - } - - .property-search-template.search-map-active .custom-controls { - right: 20px; - bottom: 20px; - position: absolute; - left: auto; - pointer-events: none; - } - - .property-search-template.search-map-active .custom-controls .zoom-controls { - margin-top: 15px; - } - - .property-search-template.search-map-active .map-draw-tooltip { - font-size: 16px; - height: 65px; - line-height: 65px; - } - - .property-search-template.search-map-active .map-search-wrapper { - left: 20px; - right: auto; - top: auto; - bottom: 25vw; - width: 165px; - overflow: visible; - } - - .property-search-template.search-map-active .map-search-wrapper a.map-search-toggle { - margin: 0; - } -} - -@media (min-width: 1200px) { - .property-search-template.search-map-active .property-result-listing.block > div:last-of-type { - max-width: calc(1400px/2 - 30px); - } -} diff --git a/blocks/property-result-map/map.js b/blocks/property-result-map/map.js deleted file mode 100644 index 2334af35..00000000 --- a/blocks/property-result-map/map.js +++ /dev/null @@ -1,54 +0,0 @@ -let alreadyDeferred = false; - -function buildCustomControls() { - const container = document.createElement('div'); - container.classList.add('map-controls-container'); - container.innerHTML = `
    - - - -
    - - -
    -
    -
    - Click points on the map to draw your search -
    -
    - -
    - `; - return container; -} - -function initGoogleMapsAPI() { - if (alreadyDeferred) { - return; - } - alreadyDeferred = true; - const script = document.createElement('script'); - script.type = 'text/partytown'; - script.id = crypto.randomUUID(); - script.innerHTML = ` - const script = document.createElement('script'); - script.type = 'module'; - script.src = '${window.hlx.codeBasePath}/blocks/property-result-map/map-delayed.js'; - document.head.append(script); - `; - document.head.append(script); -} - -export default async function renderMap(block) { - const container = document.createElement('div'); - const mobileClusterInfo = document.createElement('div'); - mobileClusterInfo.classList.add('mobile-cluster-info-window'); - const mobileInfo = document.createElement('div'); - mobileInfo.classList.add('mobile-info-window'); - container.classList.add('property-result-map-container'); - const map = document.createElement('div'); - map.classList.add('property-result-map'); - container.append(map, buildCustomControls(), mobileClusterInfo, mobileInfo); - block.append(container); - initGoogleMapsAPI(); -} diff --git a/blocks/property-search-bar/README.md b/blocks/property-search-bar/README.md new file mode 100644 index 00000000..9879bf9b --- /dev/null +++ b/blocks/property-search-bar/README.md @@ -0,0 +1,15 @@ +### Property Search Bar + +#### State Changes + +Search bar contains all filters, details on what will be searched. + +* Opening advanced search, or changing viewport to < 900px, synchronizes the Property attributes from the bar to the advanced filter list. + +* Closing the advanced search, changing the viewport to > 900px, or applying the parameters, synchronizes the Property attributes from the advanced list, to the bar. + +#### Searching + +Clicking Search on bar, or Clicking 'Apply' in the advanced filter view, will either: + 1. Change the URL to the search results page, if not there + 1. Emit the Search Event with the parameters as a payload. Search Results block handles everything else. diff --git a/blocks/property-search-bar/common-function.js b/blocks/property-search-bar/common-function.js deleted file mode 100644 index 50e212e5..00000000 --- a/blocks/property-search-bar/common-function.js +++ /dev/null @@ -1,367 +0,0 @@ -/* eslint-disable no-param-reassign, no-plusplus, no-mixed-operators, no-unused-expressions, no-nested-ternary, eqeqeq, max-len */ - -import ApplicationType from '../../scripts/apis/creg/ApplicationType.js'; - -export const TOP_LEVEL_FILTERS = { - Price: { label: 'price', type: 'range' }, - MinBedroomsTotal: { label: 'beds', type: 'select' }, - MinBathroomsTotal: { label: 'baths', type: 'select' }, - LivingArea: { label: 'square feet', type: 'range' }, -}; -export const EXTRA_FILTERS = { - PropertyType: { label: 'property type', type: 'property' }, - Features: { label: 'keyword search', type: 'keywords-search' }, - MatchAnyFeatures: { label: 'Match', type: 'child' }, - YearBuilt: { label: 'year built', type: 'range' }, - NewListing: { label: 'new listings', type: 'toggle' }, - RecentPriceChange: { label: 'recent price change', type: 'toggle' }, - OpenHouses: { label: 'open houses', type: 'open-houses' }, - Luxury: { label: 'luxury', type: 'toggle' }, - FeaturedCompany: { label: 'berkshire hathaway homeServices listings only', type: 'toggle' }, -}; - -export const BOTTOM_LEVEL_FILTERS = { - ApplicationType: { label: 'Search Types', type: 'search-types' }, - Sort: { label: 'Sort By', type: 'select' }, - Page: { label: '', type: 'child' }, -}; - -const SQUARE_FEET = [ - { value: '500', label: '500 Sq Ft' }, - { value: '750', label: '750 Sq Ft' }, - { value: '1000', label: '1,000 Sq Ft' }, - { value: '1250', label: '1,250 Sq Ft' }, - { value: '1500', label: '1,500 Sq Ft' }, - { value: '1750', label: '1,750 Sq Ft' }, - { value: '2000', label: '2,000 Sq Ft' }, - { value: '2250', label: '2,250 Sq Ft' }, - { value: '2500', label: '2,500 Sq Ft' }, - { value: '2750', label: '2,750 Sq Ft' }, - { value: '3000', label: '3,000 Sq Ft' }, - { value: '3500', label: '3,500 Sq Ft' }, - { value: '4000', label: '4,000 Sq Ft' }, - { value: '5000', label: '5,000 Sq Ft' }, - { value: '7500', label: '7,500 Sq Ft' }, -]; - -const YEAR_BUILT = [ - { value: '1900', label: '1900' }, - { value: '1920', label: '1920' }, - { value: '1940', label: '1940' }, - { value: '1950', label: '1950' }, - { value: '1960', label: '1960' }, - { value: '1970', label: '1970' }, - { value: '1980', label: '1980' }, - { value: '1990', label: '1990' }, - { value: '1995', label: '1995' }, - { value: '2000', label: '2000' }, - { value: '2005', label: '2005' }, - { value: '2014', label: '2014' }, - { value: '2015', label: '2015' }, - { value: '2016', label: '2016' }, - { value: '2017', label: '2017' }, - { value: '2018', label: '2018' }, - { value: '2019', label: '2019' }, -]; - -const SORT_BY = [ - { value: 'DISTANCE_ASCENDING', label: 'Distance' }, - { value: 'PRICE_DESCENDING', label: 'Price (Hi-Lo)' }, - { value: 'PRICE_ASCENDING', label: 'Price (Lo-Hi)' }, - { value: 'DATE_DESCENDING', label: 'DATE (NEW-OLD)' }, - { value: 'DATE_ASCENDING', label: 'DATE (OLD-NEW)' }, -]; - -export function getFilterLabel(filterName) { - let config; - switch (filterName) { - case 'Price': - case 'MinBedroomsTotal': - case 'MinBathroomsTotal': - case 'LivingArea': - config = TOP_LEVEL_FILTERS; - break; - case 'ApplicationType': - case 'Sort': - config = BOTTOM_LEVEL_FILTERS; - break; - default: - config = EXTRA_FILTERS; - break; - } - return config[filterName].label; -} -export function getConfig(filterName) { - let output = ''; - switch (filterName) { - case 'MinBedroomsTotal': - case 'MinBathroomsTotal': - output = 5; - break; - case 'LivingArea': - output = SQUARE_FEET; - break; - case 'YearBuilt': - output = YEAR_BUILT; - break; - case 'ApplicationType': - output = [ApplicationType.FOR_SALE.label, ApplicationType.FOR_RENT.label, ApplicationType.PENDING.label, ApplicationType.RECENTLY_SOLD.label]; - break; - case 'Sort': - output = SORT_BY; - break; - default: - break; - } - return output; -} - -export function toggleOverlay() { - const hideClass = 'hide'; - const overlay = document.querySelector('.property-search-bar.block .overlay'); - overlay.classList.toggle(hideClass); - if (overlay.classList.contains(hideClass)) { - document.getElementsByTagName('body')[0].classList.remove('no-scroll'); - } else { - document.getElementsByTagName('body')[0].classList.add('no-scroll'); - } -} - -export function hideOverlay() { - -} -/** - * - * @param {string} filterName - * @param {string} defaultValue - * @returns {string} - */ -function buildSelectOptions(filterName, defaultValue) { - const conf = getConfig(filterName); - let output = ``; - if (Array.isArray(conf)) { - conf.forEach((el) => { - output += ``; - }); - } else { - const labelSuf = `+ ${defaultValue.split(' ')[1]}`; - for (let i = 1; i <= conf; i += 1) { - const label = `${i} ${labelSuf}`; - output += ``; - } - } - return output; -} - -/** - * @param {string} filterName - * @param {string} defaultValue - * @returns {string} - */ -function buildListBoxOptions(filterName, defaultValue) { - const config = getConfig(filterName); - let output = `
  • ${defaultValue}
  • `; - if (Array.isArray(config)) { - config.forEach((conf) => { - output += `
  • ${conf.label}
  • `; - }); - } else { - const labelSuf = `+ ${defaultValue.split(' ')[1]}`; - for (let i = 1; i <= config; i += 1) { - const label = `${i} ${labelSuf}`; - output += `
  • ${label}
  • `; - } - } - - return output; -} - -export function addOptions(filterName, defaultValue = '', mode = '', name = '') { - let output = `
    -
    - `; - if (mode === 'multi') { - output += `
    ${defaultValue}
    `; - } - output += ` -
    -
    `; - return output; -} - -export function formatInput(string) { - return string.replace(/[/\s]/g, '-').toLowerCase(); -} - -export function getPlaceholder(country) { - return country === 'US' ? 'Enter City, Address, Zip/Postal Code, Neighborhood, School or MLS#' : 'Enter City'; -} - -export function addRangeOption(filterName) { - const config = { ...TOP_LEVEL_FILTERS, ...EXTRA_FILTERS }; - const { label } = config[filterName]; - const filterLabel = label.charAt(0).toLocaleUpperCase() + label.slice(1).toLowerCase(); - const fromLabel = 'No Min'; - const toLabel = 'No Max'; - const maxLength = 14; - let output = ''; - if (filterName === 'Price') { - output = `
    -
    - - -
    - to -
    - - -
    -
    `; - } - if (filterName === 'LivingArea' || filterName === 'YearBuilt') { - const fromName = filterName === 'LivingArea' ? 'MinLivingArea' : ''; - const toName = filterName === 'LivingArea' ? 'MaxLivingArea' : ''; - output = `
    - ${addOptions(filterName, fromLabel, 'multi', fromName)} - to - ${addOptions(filterName, toLabel, 'multi', toName)} - -
    - `; - } - return output; -} - -function abbrNum(d, h) { - h = 10 ** h; - const k = ['k', 'm', 'b', 't']; let - m; - for (m = k.length - 1; m >= 0; m--) { - const n = 10 ** (3 * (m + 1)); - if (n <= d) { - d = Math.round(d * h / n) / h; - d === 1E3 && m < k.length - 1 && (d = 1, m++); - d += k[m]; - break; - } - } - return d; -} - -export function formatPriceLabel(minPrice, maxPrice) { - const d = minPrice.replace(/[^0-9]/g, ''); - const h = maxPrice.replace(/[^0-9]/g, ''); - return d !== '' && h !== '' - ? `$${abbrNum(d, 2)} - $${abbrNum(h, 2)}` - : d !== '' ? `$${abbrNum(d, 2)}` - : d == '' && h !== '' ? `$0 - $${abbrNum(h, 2)}` - : 'Price'; -} - -export function processSearchType(value, defaultInput = ApplicationType.FOR_SALE.type) { - const name = value.replace(' ', '_').toUpperCase(); - const wrapper = document.createElement('div'); - wrapper.classList.add('filter-toggle', formatInput(value), 'flex-row', 'mb-1'); - wrapper.setAttribute('name', name); - wrapper.innerHTML = ` - -
    - - `; - return wrapper; -} - -export function buildKeywordEl(keyword, removeItemCallback) { - const item = document.createElement('span'); - const keywordInput = document.querySelector('[name="Features"] input[type="text"]'); - const keywordContainer = document.querySelector('#container-tags'); - item.classList.add('tag'); - item.textContent = `${keyword} `; - const closeBtn = document.createElement('span'); - closeBtn.classList.add('close'); - item.appendChild(closeBtn); - keywordContainer.append(item); - closeBtn.addEventListener( - 'click', - (e) => { - e.preventDefault(); - e.stopPropagation(); - const itemEl = e.target.closest('.tag'); - removeItemCallback('Features', itemEl.textContent.trim()); - itemEl.remove(); - }, - ); - keywordInput.value = ''; -} - -export function buildFilterSearchTypesElement() { - const config = getConfig('ApplicationType'); - const columns = [[config[0], config[1]], [config[2], config[3]]]; - let el; - let output = '
    '; - - columns.forEach((column) => { - output += '
    '; - column.forEach((value) => { - el = processSearchType(value); - el.querySelector('label').classList.add('text-up'); - output += el.outerHTML; - }); - output += '
    '; - }); - output += '
    '; - return output; -} - -export function hideFilter(element) { - element.classList.remove('open'); - element.querySelector('.search-results-dropdown').classList.add('hide'); -} - -export function togglePropertyForm() { - const hideClass = 'hide'; - document.querySelector('.filter-block').classList.toggle(hideClass); - toggleOverlay(); - document.querySelector('.filter-buttons').classList.toggle(hideClass); - document.querySelectorAll('.filter-container svg').forEach( - (el) => el.classList.toggle(hideClass), - ); -} - -export function closeTopLevelFilters(all = true) { - if (all && document.querySelector('[name="AdditionalFilters"] a >svg:first-of-type').classList.contains('hide')) { - togglePropertyForm(); - } - document.querySelectorAll('.container-item .header').forEach((elem) => { - if (elem.parentElement.classList.contains('open')) { - hideFilter(elem.parentElement); - } - if (elem.parentElement.querySelectorAll('.select-item').length > 0) { - elem.parentElement.querySelectorAll('.select-item').forEach((el) => { - el.classList.remove('show'); - }); - } - }); - if (document.querySelector('.search-bar').classList.contains('show-suggestions')) { - document.querySelector('.search-bar').classList.remove('show-suggestions'); - } - if (document.querySelector('[name="Sort"] .select-item').classList.contains('show')) { - document.querySelector('[name="Sort"] .select-item').classList.remove('show'); - } -} - -export function updateFilters(el) { - const filter = el.closest('.filter'); - const forRentEl = filter.querySelector('.for-rent'); - const pendingEl = filter.querySelector('.pending'); - const isForRentChecked = filter.querySelector('.for-rent .checkbox').classList.contains('checked'); - const isPendingChecked = filter.querySelector('.pending .checkbox').classList.contains('checked'); - - forRentEl.classList.toggle('disabled', isPendingChecked); - pendingEl.classList.toggle('disabled', isForRentChecked); -} diff --git a/blocks/property-search-bar/delayed.js b/blocks/property-search-bar/delayed.js new file mode 100644 index 00000000..256fbc4c --- /dev/null +++ b/blocks/property-search-bar/delayed.js @@ -0,0 +1,918 @@ +import { BREAKPOINTS } from '../../scripts/scripts.js'; +import { closeOnBodyClick, filterItemClicked } from '../shared/search/util.js'; +import { SQUARE_FEET } from './property-search-bar.js'; +import ListingType from '../../scripts/apis/creg/search/types/ListingType.js'; +import Search, { UPDATE_SEARCH_EVENT, SEARCH_URL } from '../../scripts/apis/creg/search/Search.js'; + +function toggleAdvancedFilters(e) { + const open = e.currentTarget.classList.toggle('open'); + const wrapper = e.currentTarget.closest('.search-form-wrapper'); + const filters = wrapper.querySelector('.advanced-filters'); + filters.classList.toggle('open'); + filters.scrollTo({ top: 0, behavior: 'smooth' }); + wrapper.querySelector('.search-overlay').classList.toggle('visible'); + + if (open) { + document.body.style.overflowY = 'hidden'; + } else { + document.body.style.overflowY = ''; + } +} + +const updateExpanded = (wrapper) => { + const wasOpen = wrapper.classList.contains('open'); + const thisForm = wrapper.closest('form'); + thisForm.querySelectorAll('[class*="-wrapper"][class*="open"]').forEach((item) => { + if (item !== wrapper && item.contains(wrapper)) { + return; + } + item.classList.remove('open'); + item.querySelector('[aria-expanded="true"]')?.setAttribute('aria-expanded', 'false'); + }); + if (!wasOpen) { + wrapper.classList.add('open'); + wrapper.querySelector('[aria-expanded="false"]')?.setAttribute('aria-expanded', 'true'); + closeOnBodyClick(wrapper); + } +}; + +async function observeSearchInput(e) { + e.preventDefault(); + const form = e.target.closest('form'); + try { + const mod = await import(`${window.hlx.codeBasePath}/blocks/shared/search/suggestion.js`); + mod.default(form); + } catch (error) { + // eslint-disable-next-line no-console + console.log('failed to load suggestion library', error); + } + e.target.removeEventListener('focus', observeSearchInput); +} + +const createPriceList = (d) => { + let options = ''; + const k = [10, 100, 1E3, 1E4, 1E5, 1E6]; + // eslint-disable-next-line no-plusplus + if (d) for (let m = 1; m <= 6; m++) options += ``; + return options; +}; + +function abbrNum(value, exp) { + let abbr = value; + const factor = 10 ** exp; + const k = ['k', 'm', 'b', 't']; + let m; + for (m = k.length - 1; m >= 0; m -= 1) { + const n = 10 ** (3 * (m + 1)); + if (n <= abbr) { + abbr = Math.round((value * factor) / n) / factor; + if (abbr === 1E3 && m < k.length - 1) { + abbr = 1; + m += 1; + } + abbr += k[m]; + break; + } + } + return abbr; +} + +function updatePriceLabel(wrapper) { + const min = wrapper.querySelector('input[name="min-price"]').value; + const max = wrapper.querySelector('input[name="max-price"]').value; + + let content; + const low = min.replace(/[^0-9]/g, ''); + const high = max.replace(/[^0-9]/g, ''); + if (low !== '' && high !== '') { + content = `$${abbrNum(low, 2)} -
    $${abbrNum(high, 2)}`; + } else if (low !== '') { + content = `$${abbrNum(low, 2)}`; + } else if (high !== '') { + content = `$0 -
    $${abbrNum(high, 2)}`; + } else { + content = 'Price'; + } + wrapper.querySelector('.selected span').innerHTML = content; +} + +function observePriceInput(e) { + e.preventDefault(); + const { target } = e; + const { value } = target; + const datalist = target.closest('.input-dropdown').querySelector('datalist'); + datalist.innerHTML = createPriceList(value); +} + +function filterSelectClicked(e) { + e.preventDefault(); + e.stopPropagation(); + updateExpanded(e.currentTarget.closest('.select-wrapper')); +} + +function rangeSelectClicked(e) { + e.preventDefault(); + e.stopPropagation(); + updateExpanded(e.currentTarget.closest('.range-wrapper')); +} + +function sqftSelectClicked(e) { + e.preventDefault(); + e.stopPropagation(); + updateExpanded(e.currentTarget.closest('.select-wrapper')); +} + +function updateSqftLabel(wrapper) { + const min = wrapper.querySelector('#list-min-sqft li.selected'); + const max = wrapper.querySelector('#list-max-sqft li.selected'); + + let label = wrapper.querySelector('div.selected').getAttribute('aria-label'); + + if (min.getAttribute('data-value') || max.getAttribute('data-value')) { + label = `${min.textContent} -
    ${max.textContent}`; + } + wrapper.querySelector('span').innerHTML = label; +} + +function addKeyword(wrapper, value) { + const keyword = document.createElement('div'); + keyword.classList.add('keyword'); + keyword.innerHTML = ` + ${value} + X + `; + keyword.querySelector('.close').addEventListener('click', (localE) => { + localE.stopPropagation(); + localE.preventDefault(); + localE.currentTarget.closest('.keyword').remove(); + }); + + wrapper.querySelector('.keywords-list').append(keyword); +} + +async function updateParameters() { + const form = document.querySelector('.property-search-bar.block form'); + // Build Search Obj and store it. + let search; + if (window.location.pathname !== SEARCH_URL) { + let type = form.querySelector('input[name="type"]').value; + if (type) { + type = type.replaceAll(/\s/g, ''); + if (type === 'ZipCode') { + type = 'PostalCode'; + } else if (type === 'MLS #') { + type = 'MLSListingKey'; + } + } + search = await Search.load(type); + } else { + search = await Search.fromQueryString(window.location.search); + } + let input = form.querySelector('.suggester-input input[type="text"]'); + if (input.value) search.input = input.value; + input = form.querySelector('.result-filters input[name="min-price"]'); + if (input.value) search.minPrice = input.value; + input = form.querySelector('.result-filters input[name="max-price"]'); + if (input.value) search.maxPrice = input.value; + input = form.querySelector('.result-filters .bedrooms select'); + if (input.value) search.minBedrooms = input.value; + input = form.querySelector('.result-filters .bathrooms select'); + if (input.value) search.minBathrooms = input.value; + input = form.querySelector('.result-filters #min-sqft select'); + if (input.value) search.minSqft = input.value; + input = form.querySelector('.result-filters #max-sqft select'); + if (input.value) search.maxSqft = input.value; + search.listingTypes = []; + input = form.querySelector('.listing-types input[name="FOR_SALE"]'); + if (input.checked) search.addListingType(ListingType.FOR_SALE); + input = form.querySelector('.listing-types input[name="FOR_RENT"]'); + if (input.checked) search.addListingType(ListingType.FOR_RENT); + input = form.querySelector('.listing-types input[name="PENDING"]'); + if (input.checked) search.addListingType(ListingType.PENDING); + input = form.querySelector('.listing-types input[name="RECENTLY_SOLD"]'); + if (input.checked) search.addListingType(ListingType.RECENTLY_SOLD); + search.propertyTypes = []; + form.querySelectorAll('.property-types button.selected').forEach((btn) => search.addPropertyType(btn.name)); + search.keyword = []; + form.querySelectorAll('.keywords .keywords-list .keyword span:first-of-type').forEach((kw) => search.keywords.push(kw.textContent)); + if (form.querySelector('.keywords input[name="matchType"]').value === 'any') { + search.matchAnyKeyword = true; + } + input = form.querySelector('.year-range #min-year select'); + if (input.value) search.minYear = input.value; + input = form.querySelector('.year-range #max-year select'); + if (input.value) search.maxYear = input.value; + search.isNew = form.querySelector('.is-new input').checked; + search.priceChange = form.querySelector('.price-change input').checked; + input = form.querySelector('.open-houses input'); + if (input.checked) search.openHouses = form.querySelector('.open-houses input[name="open-houses-timeframe"]:checked').value; + input = form.querySelector('.lux input'); + if (input.checked) search.luxury = true; + input = form.querySelector('.bhhs-only input'); + if (input.checked) search.bhhsOnly = true; + + form.querySelector('a.filter.open')?.dispatchEvent(new MouseEvent('click')); + if (window.location.pathname !== SEARCH_URL) { + window.location = `${SEARCH_URL}?${search.asURLSearchParameters().toString()}`; + } else { + window.dispatchEvent(new CustomEvent(UPDATE_SEARCH_EVENT, { detail: { search } })); + } +} + +function syncToBar() { + const bar = document.querySelector('.property-search-bar.block form .result-filters'); + const attrib = document.querySelector('.property-search-bar.block form .advanced-filters .attributes'); + bar.querySelector('input[name="min-price"]').value = attrib.querySelector('input[name="adv-min-price"]').value; + bar.querySelector('input[name="max-price"]').value = attrib.querySelector('input[name="adv-max-price"]').value; + updatePriceLabel(bar.querySelector('.range-wrapper.price')); + + const bedrooms = attrib.querySelector('.bedrooms li input:checked').value; + bar.querySelector(`.select-wrapper.bedrooms li[data-value="${bedrooms}"]`).dispatchEvent(new MouseEvent('click')); + const bathrooms = attrib.querySelector('.bathrooms li input:checked').value; + bar.querySelector(`.select-wrapper.bathrooms li[data-value="${bathrooms}"]`).dispatchEvent(new MouseEvent('click')); + + const minSqft = attrib.querySelector('#adv-min-sqft select').value; + bar.querySelector(`.range-wrapper.sqft #min-sqft ul li[data-value="${minSqft}"]`).dispatchEvent(new MouseEvent('click')); + const maxSqft = attrib.querySelector('#adv-max-sqft select').value; + bar.querySelector(`.range-wrapper.sqft #max-sqft ul li[data-value="${maxSqft}"]`).dispatchEvent(new MouseEvent('click')); +} + +function syncToAdvanced() { + const bar = document.querySelector('.property-search-bar.block form .result-filters'); + const attrib = document.querySelector('.property-search-bar.block form .advanced-filters .attributes'); + attrib.querySelector('input[name="adv-min-price"]').value = bar.querySelector('input[name="min-price"]').value; + attrib.querySelector('input[name="adv-max-price"]').value = bar.querySelector('input[name="max-price"]').value; + + const bedrooms = bar.querySelector('.bedrooms select').value; + attrib.querySelector(`.bedrooms li[data-value="${bedrooms}"] input`).checked = true; + const bathrooms = bar.querySelector('.bathrooms select').value; + attrib.querySelector(`.bathrooms li[data-value="${bathrooms}"] input`).checked = true; + + const minSqft = bar.querySelector('#min-sqft select').value; + attrib.querySelector(`.sqft #adv-min-sqft ul li[data-value="${minSqft}"]`).dispatchEvent(new MouseEvent('click')); + const maxSqft = bar.querySelector('#max-sqft select').value; + attrib.querySelector(`.sqft #adv-max-sqft ul li[data-value="${maxSqft}"]`).dispatchEvent(new MouseEvent('click')); +} + +function resetForm(e) { + e.stopPropagation(); + e.preventDefault(); + const form = e.currentTarget.closest('form'); + form.querySelectorAll('.advanced-filters .listing-types input[checked="checked"]').forEach((input) => input.closest('.filter-toggle').dispatchEvent(new MouseEvent('click'))); + form.querySelector('.advanced-filters input[name="FOR_SALE"]').closest('.filter-toggle').dispatchEvent(new MouseEvent('click')); + form.querySelectorAll('.advanced-filters .range-wrapper.price input[type="text"]').forEach((input) => { + input.value = ''; + }); + form.querySelectorAll('.advanced-filters .bedrooms li[data-value=""] input').forEach((li) => li.dispatchEvent(new MouseEvent('click'))); + form.querySelectorAll('.advanced-filters .bathrooms li[data-value=""] input').forEach((li) => li.dispatchEvent(new MouseEvent('click'))); + form.querySelectorAll('.advanced-filters .sqft li[data-value=""]').forEach((li) => li.dispatchEvent(new MouseEvent('click'))); + form.querySelectorAll('.advanced-filters .property-types button').forEach((button) => button.classList.add('selected')); + form.querySelectorAll('.advanced-filters .property-types button[name="COMMERCIAL"]').forEach((button) => button.classList.remove('selected')); + form.querySelector('.advanced-filters .property-types .all input[type="checkbox"]').checked = false; + form.querySelectorAll('.advanced-filters .misc .year-range li[data-value=""]').forEach((li) => li.dispatchEvent(new MouseEvent('click'))); + form.querySelectorAll('.advanced-filters .misc .filter-toggle input[checked="checked"]').forEach((input) => input.closest('.filter-toggle').dispatchEvent(new MouseEvent('click'))); +} + +/** + * Updates the bar and advanced form parameters based on the provided Search object. + * @param {Search} search + */ +// eslint-disable-next-line import/prefer-default-export +export function updateForm(search) { + const bar = document.querySelector('.property-search-bar.block .search-form-wrapper'); + const advanced = document.querySelector('.property-search-bar.block .advanced-filters'); + + bar.querySelector('input[name="type"]').value = search.type; + bar.querySelector('input[name="keyword"]').value = search.input || ''; + bar.querySelector('input[name="min-price"]').value = search.minPrice || ''; + advanced.querySelector('input[name="adv-min-price"]').value = search.minPrice || ''; + bar.querySelector('input[name="max-price"]').value = search.maxPrice || ''; + advanced.querySelector('input[name="adv-max-price"]').value = search.maxPrice || ''; + updatePriceLabel(bar.querySelector('.range-wrapper.price')); + + const beds = search.minBedrooms; + let display; + bar.querySelector('.select-wrapper.bedrooms ul li.selected')?.classList.toggle('selected'); + if (beds) { + bar.querySelector(`.select-wrapper.bedrooms select option[value="${beds}"]`).selected = true; + advanced.querySelector(`div.bedrooms input[value="${beds}"]`).checked = true; + const selected = bar.querySelector(`.select-wrapper.bedrooms ul li[data-value="${beds}"]`); + selected.classList.add('selected'); + display = selected.textContent; + } else { + bar.querySelector('.select-wrapper.bedrooms select option[value=""]').selected = true; + advanced.querySelector('div.bedrooms input[value=""]').checked = true; + const selected = bar.querySelector('.select-wrapper.bedrooms ul li[data-value=""]'); + selected.classList.add('selected'); + display = selected.textContent; + } + + bar.querySelector('.select-wrapper.bedrooms div.selected span').textContent = display; + const baths = search.minBathrooms; + bar.querySelector('.select-wrapper.bathrooms ul li.selected')?.classList.toggle('selected'); + if (baths) { + bar.querySelector(`.select-wrapper.bathrooms select option[value="${baths}"]`).selected = true; + advanced.querySelector(`div.bathrooms input[value="${baths}"]`).checked = true; + const selected = bar.querySelector(`.select-wrapper.bathrooms ul li[data-value="${baths}"]`); + selected.classList.add('selected'); + display = selected.textContent; + } else { + bar.querySelector('.select-wrapper.bathrooms select option[value=""]').selected = true; + advanced.querySelector('div.bathrooms input[value=""]').checked = true; + const selected = bar.querySelector('.select-wrapper.bathrooms ul li[data-value=""]'); + selected.classList.add('selected'); + display = selected.textContent; + } + bar.querySelector('.select-wrapper.bathrooms div.selected span').textContent = display; + + const { minSqft, maxSqft } = search; + bar.querySelector('.range-wrapper.sqft #min-sqft ul li.selected').classList.remove('selected'); + advanced.querySelector('div.sqft #adv-min-sqft ul li.selected').classList.remove('selected'); + if (minSqft) { + bar.querySelector(`.range-wrapper.sqft #min-sqft select option[value="${minSqft}"]`).selected = true; + bar.querySelector(`.range-wrapper.sqft #min-sqft ul li[data-value="${minSqft}"]`).classList.add('selected'); + advanced.querySelector(`div.sqft #adv-min-sqft select option[value="${minSqft}"]`).selected = true; + advanced.querySelector(`div.sqft #adv-min-sqft ul li[data-value="${minSqft}"]`).classList.add('selected'); + } else { + bar.querySelector('.range-wrapper.sqft #min-sqft select option[value=""]').selected = true; + bar.querySelector('.range-wrapper.sqft #min-sqft ul li[data-value=""]').classList.add('selected'); + advanced.querySelector('div.sqft #adv-min-sqft select option[value=""]').selected = true; + advanced.querySelector('div.sqft #adv-min-sqft ul li[data-value=""]').classList.add('selected'); + } + bar.querySelector('.range-wrapper.sqft #min-sqft div.selected span').textContent = bar.querySelector('.range-wrapper.sqft #min-sqft ul li.selected').textContent; + advanced.querySelector('div.sqft #adv-min-sqft div.selected span').textContent = advanced.querySelector('div.sqft #adv-min-sqft ul li.selected').textContent; + + bar.querySelector('.range-wrapper.sqft #max-sqft ul li.selected').classList.remove('selected'); + advanced.querySelector('div.sqft #adv-max-sqft ul li.selected').classList.remove('selected'); + if (maxSqft) { + bar.querySelector(`.range-wrapper.sqft #max-sqft select option[value="${maxSqft}"]`).selected = true; + bar.querySelector(`.range-wrapper.sqft #max-sqft ul li[data-value="${maxSqft}"]`).classList.add('selected'); + advanced.querySelector(`div.sqft #adv-max-sqft select option[value="${maxSqft}"]`).selected = true; + advanced.querySelector(`div.sqft #adv-max-sqft ul li[data-value="${maxSqft}"]`).classList.add('selected'); + } else { + bar.querySelector('.range-wrapper.sqft #max-sqft select option[value=""]').selected = true; + bar.querySelector('.range-wrapper.sqft #max-sqft ul li[data-value=""]').classList.add('selected'); + advanced.querySelector('div.sqft #adv-max-sqft select option[value=""]').selected = true; + advanced.querySelector('div.sqft #adv-max-sqft ul li[data-value=""]').classList.add('selected'); + } + bar.querySelector('.range-wrapper.sqft #max-sqft div.selected span').textContent = bar.querySelector('.range-wrapper.sqft #max-sqft ul li.selected').textContent; + advanced.querySelector('div.sqft #adv-max-sqft div.selected span').textContent = advanced.querySelector('div.sqft #adv-max-sqft ul li.selected').textContent; + updateSqftLabel(bar.querySelector('.range-wrapper.sqft')); + + advanced.querySelectorAll('.listing-types .filter-toggle.disabled').forEach((t) => t.classList.remove('disabled')); + advanced.querySelectorAll('.listing-types .filter-toggle input[type="checkbox"]').forEach((c) => { + c.removeAttribute('checked'); + c.nextElementSibling.classList.remove('checked'); + }); + search.listingTypes.forEach((t) => { + const chkbx = advanced.querySelector(`.listing-types .filter-toggle input[name="${t.type}"]`); + chkbx.checked = true; + chkbx.nextElementSibling.classList.add('checked'); + if (t.type === ListingType.FOR_RENT.type) { + advanced.querySelector(`.listing-types .filter-toggle input[name="${ListingType.PENDING.type}"]`).closest('.filter-toggle').classList.add('disabled'); + } else if (t.type === ListingType.PENDING.type) { + advanced.querySelector(`.listing-types .filter-toggle input[name="${ListingType.FOR_RENT.type}"]`).closest('.filter-toggle').classList.add('disabled'); + } + }); + + advanced.querySelectorAll('.property-types button.selected').forEach((b) => b.classList.remove('selected')); + search.propertyTypes.forEach((t) => { + advanced.querySelector(`.property-types button[name="${t.name}"]`).classList.add('selected'); + }); + const unselected = advanced.querySelector('.property-types button:not(.selected)'); + if (unselected) { + advanced.querySelector('.property-types .all label input').checked = false; + } else { + advanced.querySelector('.property-types .all label input').checked = true; + } + + const kwWrapper = advanced.querySelector('.keywords'); + kwWrapper.querySelector('.keywords-list').replaceChildren(); + search.keywords.forEach((kw) => addKeyword(kwWrapper, kw)); + if (search.matchAnyKeyword) { + advanced.querySelector('.keywords input[value="any"]').checked = true; + } else { + advanced.querySelector('.keywords input[value="all"]').checked = true; + } + + const { minYear, maxYear } = search; + const minYearWrapper = advanced.querySelector('div.year-range #min-year'); + minYearWrapper.querySelector('ul li.selected').classList.remove('selected'); + if (minYear) { + minYearWrapper.querySelector(`select option[value="${minYear}"]`).selected = true; + minYearWrapper.querySelector(`ul li[data-value="${minYear}"]`).classList.add('selected'); + } else { + minYearWrapper.querySelector('select option[value=""]').selected = true; + minYearWrapper.querySelector('ul li[data-value=""]').classList.add('selected'); + } + minYearWrapper.querySelector('div.selected span').textContent = minYearWrapper.querySelector('ul li.selected').textContent; + + const maxYearWrapper = advanced.querySelector('div.year-range #max-year'); + maxYearWrapper.querySelector('ul li.selected').classList.remove('selected'); + if (maxYear) { + maxYearWrapper.querySelector(`select option[value="${maxYear}"]`).selected = true; + maxYearWrapper.querySelector(`ul li[data-value="${maxYear}"]`).classList.add('selected'); + } else { + maxYearWrapper.querySelector('select option[value=""]').selected = true; + maxYearWrapper.querySelector('ul li[data-value=""]').classList.add('selected'); + } + maxYearWrapper.querySelector('div.selected span').textContent = maxYearWrapper.querySelector('ul li.selected').textContent; + + if (search.isNew) { + advanced.querySelector('.is-new .filter-toggle input').checked = true; + advanced.querySelector('.is-new .filter-toggle .checkbox').classList.add('checked'); + } else { + advanced.querySelector('.is-new .filter-toggle input').checked = false; + advanced.querySelector('.is-new .filter-toggle .checkbox').classList.remove('checked'); + } + + if (search.priceChange) { + advanced.querySelector('.price-change .filter-toggle input').checked = true; + advanced.querySelector('.price-change .filter-toggle .checkbox').classList.add('checked'); + } else { + advanced.querySelector('.price-change .filter-toggle input').checked = false; + advanced.querySelector('.price-change .filter-toggle .checkbox').classList.remove('checked'); + } + + if (search.openHouses) { + advanced.querySelector('.open-houses .filter-toggle input').checked = true; + advanced.querySelector('.open-houses .filter-toggle .checkbox').classList.add('checked'); + advanced.querySelector('.open-houses .open-houses-timeframe').classList.add('visible'); + advanced.querySelector(`.open-houses input[value=${search.openHouses.name}]`).checked = true; + } else { + advanced.querySelector('.open-houses .filter-toggle input').checked = false; + advanced.querySelector('.open-houses .filter-toggle .checkbox').classList.remove('checked'); + advanced.querySelector('.open-houses .open-houses-timeframe').classList.remove('visible'); + } + + if (search.luxury) { + advanced.querySelector('.lux .filter-toggle input').checked = true; + advanced.querySelector('.lux .filter-toggle .checkbox').classList.add('checked'); + } else { + advanced.querySelector('.lux .filter-toggle input').checked = false; + advanced.querySelector('.lux .filter-toggle .checkbox').classList.remove('checked'); + } + + if (search.luxury) { + advanced.querySelector('.bhhs-only .filter-toggle input').checked = true; + advanced.querySelector('.bhhs-only .filter-toggle .checkbox').classList.add('checked'); + } else { + advanced.querySelector('.bhhs-only .filter-toggle input').checked = false; + advanced.querySelector('.bhhs-only .filter-toggle .checkbox').classList.remove('checked'); + } +} + +function buildAdvancedFilters() { + const wrapper = document.querySelector('.property-search-bar.block .advanced-filters'); + wrapper.innerHTML = ` +
    + +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    + +
    + +
    +
    +
    +
    + +
    +
    + + +
    + to +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + +
      +
    • No Min
    • +
    +
    + to +
    + + +
      +
    • No Max
    • +
    +
    +
    +
    +
    +
    + +
    + + + + + + +
    +
    + +
    +
    +
    + +
    + + +
    +
    + +
    +
    + + + +
    +
    +
    +
    + +
    +
    + + +
      +
    • No Min
    • +
    +
    + to +
    + + +
      +
    • No Max
    • +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    +

    + Apply +

    +

    + Cancel +

    +

    + Reset +

    +
    + `; + + wrapper.querySelectorAll('#adv-min-sqft, #adv-max-sqft').forEach((item) => { + SQUARE_FEET.forEach((b) => { + const opt = document.createElement('option'); + opt.value = b.value; + opt.textContent = b.label; + item.querySelector('select').append(opt); + const li = document.createElement('li'); + li.setAttribute('data-value', b.value); + li.setAttribute('role', 'option'); + li.textContent = b.label; + item.querySelector('ul').append(li); + }); + }); + + wrapper.querySelectorAll('#min-year, #max-year').forEach((item) => { + let year = new Date().getFullYear(); + const select = item.querySelector('select'); + const ul = item.querySelector('ul'); + + const addOption = (value) => { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = value; + select.append(opt); + const li = document.createElement('li'); + li.setAttribute('data-value', value); + li.setAttribute('role', 'option'); + li.textContent = value; + ul.append(li); + }; + + // Last 6 years individually + for (let i = 0; i < 6; i += 1) { + addOption(year); + year -= 1; + } + + // 6 blocks of 5 year increments + year -= year % 5; + for (let i = 1; i < 6; i += 1) { + addOption(year); + year -= 5; + } + + // 6 blocks of 10 year increments + year -= year % 10; + for (let i = 1; i < 6; i += 1) { + addOption(year); + year -= 10; + } + + // remaining at 20 year increments + while (year >= 1900) { + addOption(year); + year -= 20; + } + }); +} + +function observeForm(form) { + const searchInput = form.querySelector('.suggester-input input'); + searchInput.addEventListener('focus', observeSearchInput); + + form.querySelector('a.search-submit').addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!BREAKPOINTS.medium.matches) { + syncToBar(); + } + updateParameters(); + }); + + form.querySelectorAll('.result-filters > .select-wrapper div.selected, .advanced-filters .misc .select-wrapper div.selected').forEach((button) => { + button.addEventListener('click', filterSelectClicked); + }); + + form.querySelectorAll('.select-wrapper .select-items li').forEach((li) => { + li.addEventListener('click', filterItemClicked); + }); + + form.querySelectorAll('.range-wrapper > div.selected').forEach((button) => { + button.addEventListener('click', rangeSelectClicked); + }); + + form.querySelectorAll('.range-wrapper .range-items div[id="min-price"], .range-wrapper .range-items div[id="max-price"]').forEach((price) => { + price.addEventListener('keyup', (e) => { + observePriceInput(e); + updatePriceLabel(price.closest('.range-wrapper')); + }); + }); + + form.querySelectorAll('.filter-toggle').forEach((t) => { + t.addEventListener('click', (e) => { + e.preventDefault(); + const { currentTarget } = e; + const ipt = currentTarget.querySelector('input'); + ipt.checked = currentTarget.querySelector('div.checkbox').classList.toggle('checked'); + }); + }); + + form.querySelectorAll('.range-wrapper .range-items div[id="adv-min-price"], .range-wrapper .range-items div[id="adv-max-price"]').forEach((price) => { + price.addEventListener('keyup', observePriceInput); + }); + + form.querySelector('.listing-types').addEventListener('click', (e) => { + e.preventDefault(); + const input = e.target.closest('.filter-toggle')?.querySelector('input'); + if (input && input.value === ListingType.FOR_RENT.type) { + e.currentTarget.querySelector(`input[value="${ListingType.PENDING.type}"]`).closest('.filter-toggle').classList.toggle('disabled'); + } else if (input && input.value === ListingType.PENDING.type) { + e.currentTarget.querySelector(`input[value="${ListingType.FOR_RENT.type}"]`).closest('.filter-toggle').classList.toggle('disabled'); + } + }); + + form.querySelectorAll('.range-items div[id$="-sqft"] > .selected').forEach((sqft) => { + sqft.addEventListener('click', sqftSelectClicked); + }); + + form.querySelectorAll('.range-wrapper .range-items div[id$="-sqft"] .select-items li').forEach((li) => { + li.addEventListener('click', (e) => { + e.preventDefault(); + updateSqftLabel(e.currentTarget.closest('.range-wrapper')); + }); + }); + + const allTypes = form.querySelector('.property-types .all'); + form.querySelectorAll('.property-types .options button').forEach((b) => { + b.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const exists = e.currentTarget.classList.toggle('selected'); + if (!exists) { + allTypes.querySelector('input[type="checkbox"]').checked = false; + } + }); + }); + + form.querySelector('.property-types .all label').addEventListener('click', (e) => { + if (e.currentTarget.querySelector('input').checked) { + e.currentTarget.closest('.property-types').querySelectorAll('.options button').forEach((b) => b.classList.add('selected')); + } else { + e.currentTarget.closest('.property-types').querySelectorAll('.options button').forEach((b) => b.classList.remove('selected')); + } + }); + + form.querySelector('.advanced-filters .keywords button').addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const wrapper = e.currentTarget.closest('.keywords'); + const input = wrapper.querySelector('.keywords-input input[type="text"]'); + const { value } = input; + if (!value) { + return; + } + addKeyword(wrapper, value); + input.value = ''; + }); + + form.querySelectorAll('.year-range .select-wrapper div.selected').forEach((button) => { + button.addEventListener('click', filterSelectClicked); + }); + + form.querySelector('.advanced-filters .open-houses .filter-toggle').addEventListener('click', (e) => { + const input = e.currentTarget.querySelector('input'); + const timeframe = e.currentTarget.closest('.open-houses').querySelector('.open-houses-timeframe'); + if (input.checked) { + timeframe.classList.add('visible'); + } else { + timeframe.classList.remove('visible'); + } + }); + + const filterBtn = form.querySelector('a.filter'); + filterBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + window.scrollTo({ top: 0, behavior: 'smooth' }); + toggleAdvancedFilters(e); + }); + + form.querySelector('a#search-apply').addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (!BREAKPOINTS.medium.matches) { + syncToBar(); + } + updateParameters(); + }); + + form.querySelector('a#search-cancel').addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + filterBtn.dispatchEvent(new MouseEvent('click')); + }); + + form.querySelector('a#search-reset').addEventListener('click', resetForm); + + BREAKPOINTS.medium.addEventListener('change', (e) => { + if (e.matches) { + syncToBar(); + } else { + syncToAdvanced(); + } + }); +} + +buildAdvancedFilters(); +observeForm(document.querySelector('.property-search-bar.block form')); +// Reset bar if user moves through history state. +window.addEventListener('popstate', async () => { + document.querySelectorAll('.property-search-bar .open').forEach((el) => el.classList.remove('open')); + document.querySelectorAll('.property-search-bar .search-overlay.visible').forEach((el) => el.classList.remove('visible')); + document.querySelectorAll('.property-search-bar [aria-expanded="true"]').forEach((el) => el.setAttribute('aria-expanded', 'false')); +}); diff --git a/blocks/property-search-bar/filter-processor.js b/blocks/property-search-bar/filter-processor.js deleted file mode 100644 index caa77140..00000000 --- a/blocks/property-search-bar/filter-processor.js +++ /dev/null @@ -1,367 +0,0 @@ -import { - setParam, getParam, removeParam, getSearchObject, buildUrl, -} from '../../scripts/search.js'; -import { - buildKeywordEl, formatPriceLabel, - TOP_LEVEL_FILTERS, EXTRA_FILTERS, BOTTOM_LEVEL_FILTERS, getConfig, -} from './common-function.js'; - -import { - propertySearch, -} from '../../scripts/apis/creg/creg.js'; - -import { setPropertyDetails as setResults } from '../../scripts/search/results.js'; -import SearchParameters from '../../scripts/apis/creg/SearchParameters.js'; - -import SearchType from '../../scripts/apis/creg/SearchType.js'; -import ApplicationType from '../../scripts/apis/creg/ApplicationType.js'; - -export function searchProperty() { - if (document.querySelector('.property-result-content')) { - document.querySelector('.property-result-content').remove(); - } - if (document.querySelector('.property-result-map-container .disclaimer')) { - document.querySelector('.property-result-map-container .disclaimer').remove(); - } - document.querySelector('.search-results-loader').style.display = 'block'; - const overlay = document.querySelector('.search-results-loader-image'); - overlay.classList.remove('exit'); - overlay.classList.add('enter'); - const type = getParam('SearchType'); - const searchParams = getSearchObject(); - const params = new SearchParameters(SearchType[type]); - const result = { - properties: [], - cont: 0, - disclaimer: {}, - listingClusters: [], - result: {}, - }; - // set params from session storage - Object.keys(searchParams).forEach((key) => { - if (key === 'ApplicationType') { - params.applicationTypes = searchParams[key].split(',').map((propertyType) => ApplicationType[propertyType]); - } else if (key === 'PropertyType') { - params.propertyTypes = searchParams[key].split(','); - } else if (key === 'franchiseeCode') { - params.franchisee = searchParams[key]; - } else if (key === 'Sort') { - params.sortBy = searchParams[key]; - } else if (key === 'isFranchisePage') { - // do nothing - } else { - params[key] = searchParams[key]; - } - }); - - propertySearch(params).then((results) => { - result.properties = results.properties; - result.count = results['@odata.count']; - result.disclaimer = results.disclaimer; - result.listingClusters = results.listingClusters; - result.result = results; - setResults(result); - }).catch(() => { - setResults(result); - }).finally(() => { - document.querySelector('.search-results-loader').style.display = 'none'; - overlay.classList.remove('enter'); - overlay.classList.add('exit'); - }); - - // update url - const nextUrl = buildUrl(); - const nextTitle = 'Property Search Results Commonwealth Real Estate | Berkshire Hathaway HomeServices'; - const nextState = { additionalInformation: 'Updated the URL with JS' }; - window.history.replaceState(nextState, nextTitle, nextUrl); -} -/** - * - * @param filterName - * @param value - * @returns {string|string|*} - */ -export function formatValue(filterName, value) { - let conf; let - formattedValue = ''; - switch (filterName) { - case 'Price': - formattedValue = formatPriceLabel(value.min, value.max); - break; - case 'MinBedroomsTotal': - formattedValue = value ? `${value}+ Beds` : 'Any Beds'; - break; - case 'MinBathroomsTotal': - formattedValue = value ? `${value}+ Baths` : 'Any Baths'; - break; - case 'LivingArea': - formattedValue = `${value.min}Sq Ft-${value.max} Sq Ft`; - if (value.min === '') { - formattedValue = `no min- ${value.max} Sq Ft`; - } - if (value.max === '') { - formattedValue = `${value.min} Sq Ft - no max`; - } - if (value.min === '' && value.max === '') { - formattedValue = 'square feet'; - } - break; - case 'Sort': - conf = getConfig(filterName); - for (let i = 0; i < conf.length; i += 1) { - if (conf[i].value === value) { - formattedValue = conf[i].label; - } - } - break; - default: - formattedValue = value; - } - return formattedValue; -} - -/** - * Get filter value - * @param {string} filterName - * @returns {string} - * - */ -export function getValueFromStorage(filterName) { - let minValue = ''; - let maxValue = ''; - let value = ''; - switch (filterName) { - case 'Price': - case 'LivingArea': - value = { min: getParam(`Min${filterName}`) ?? '', max: getParam(`Max${filterName}`) ?? '' }; - break; - case 'Features': - case 'ApplicationType': - case 'PropertyType': - value = getParam(filterName) ? getParam(filterName).split(',') : []; - break; - case 'YearBuilt': - [minValue, maxValue] = (getParam('YearBuilt') || '-').split('-').map((val) => { - if (val === '1899') return 'No Min'; - if (val === '2100') return 'No Max'; - return val; - }); - value = { min: minValue, max: maxValue }; - break; - case 'FeaturedCompany': - value = getParam('FeaturedCompany') === 'BHHS'; - break; - default: - value = getParam(filterName) ?? false; - } - return value; -} - -/** - * - * @param {string} name - * @param {string|obj} value - */ -export function setFilterValue(name, value) { - let params; - let values; - switch (name) { - case 'PropertyType': - params = getValueFromStorage('PropertyType'); - if (value.length === 1) { - params.push(value); - params = params.join(','); - setParam('PropertyType', params); - } else if (value.length > 1) { - values = value.split(','); - values = [...params, ...values]; - values = [...new Set(values)]; - values = values.join(','); - setParam('PropertyType', values); - } - break; - case 'FeaturedCompany': - // eslint-disable-next-line no-unused-expressions - value ? setParam('FeaturedCompany', 'BHHS') : removeParam('FeaturedCompany'); - break; - case 'Luxury': - case 'RecentPriceChange': - // eslint-disable-next-line no-unused-expressions - value ? setParam(name, true) : removeParam(name); - break; - case 'Features': - params = getParam(name) ?? ''; - params = params.length > 0 ? params.concat(',', value) : value; - values = params.split(','); - values = [...new Set(values)]; - setParam(name, values.join(',')); - break; - case 'ApplicationType': - if (value.length > 0) { - setParam(name, value); - values = value.split(','); - params = values.map((val) => { - let param; - if (val === ApplicationType.FOR_SALE.type - || val === ApplicationType.FOR_RENT.type) param = 1; - if (val === ApplicationType.PENDING.type) param = 2; - if (val === ApplicationType.RECENTLY_SOLD.type) param = 3; - return param; - }); - const unique = [...new Set(params)]; - setParam('ListingStatus', unique.join(',')); - } else { - removeParam(name); - removeParam('ListingStatus'); - } - break; - case 'MinPrice': - case 'MaxPrice': - case 'MinBedroomsTotal': - case 'MinBathroomsTotal': - // eslint-disable-next-line no-unused-expressions - value.length > 0 ? setParam(name, value) : removeParam(name); - break; - default: - setParam(name, value); - } -} -export function removeFilterValue(name, value = '') { - let params; - let paramsToArray; - switch (name) { - case 'Features': - params = getParam('Features') ?? ''; - paramsToArray = params.split(','); - paramsToArray = paramsToArray.filter((i) => i !== value); - params = paramsToArray.join(','); - setParam('Features', params); - break; - case 'PropertyType': - if (value.length > 0) { - params = getValueFromStorage('PropertyType'); - params = params.filter((i) => i !== value); - setParam('PropertyType', params.join(',')); - } else { - removeParam('PropertyType'); - } - break; - default: - removeParam(name); - } -} - -export function setInitialValuesFromUrl() { - const url = window.location.href; - const queryString = url.split('?')[1]; - if (queryString) { - queryString.split('&').forEach((query) => { - // eslint-disable-next-line prefer-const - let [key, value] = query.split('='); - value = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : ''; - setFilterValue(key, value); - }); - } -} - -export function populatePreSelectedFilters(topMenu = true) { - let filters; let - el; - let value = ''; - if (topMenu) { - filters = { ...TOP_LEVEL_FILTERS, ...BOTTOM_LEVEL_FILTERS }; - Object.keys(filters).forEach((name) => { - const selector = `[name="${name}"] .title span`; - value = getValueFromStorage(name); - if (Object.keys(BOTTOM_LEVEL_FILTERS).includes(name)) { - if (name === 'ApplicationType') { - value.forEach((key) => { - document.querySelector(`[name="${name}"] > [name="${key}"] .checkbox`).classList.add('checked'); - }); - } - if (name === 'Sort' && value) { - el = document.querySelector('[name="Sort"]'); - el.querySelector('.select-selected').innerText = formatValue(name, value); - el.querySelector('.highlighted').classList.toggle('highlighted'); - el.querySelector(`[data-value="${value}"]`).classList.toggle('highlighted'); - } - } else { - document.querySelector(selector).innerText = formatValue(name, value); - } - }); - } else { - const storageKeyToName = { ...TOP_LEVEL_FILTERS, ...EXTRA_FILTERS, ...BOTTOM_LEVEL_FILTERS }; - Object.keys(storageKeyToName).forEach((name) => { - value = getValueFromStorage(name); - let min; - let max; - let filter; - switch (name) { - case 'Price': - document.querySelector('.filter [name="MinPrice"]').value = value.min; - document.querySelector('.filter [name="MaxPrice"]').value = value.max; - break; - case 'MinBedroomsTotal': - case 'MinBathroomsTotal': - value = value || 'Any'; - document.querySelectorAll(`[name="${name}"] input`).forEach( - (input) => { input.checked = (input.value === value); }, - ); - break; - case 'LivingArea': - document.querySelector('.filter [name="MinLivingArea"]').innerText = value.min.length > 0 ? `${value.min} Sq Ft` : 'No Min'; - document.querySelector('.filter [name="MaxLivingArea"]').innerText = value.max.length > 0 ? `${value.max} Sq Ft` : 'No Max'; - document.querySelectorAll('.filter [name="MinLivingArea"] ~ul li').forEach((li) => { - li.classList.toggle('highlighted', li.getAttribute('data-value') === value.min); - }); - document.querySelectorAll('.filter [name="MaxLivingArea"] ~ul li').forEach((li) => { - li.classList.toggle('highlighted', li.getAttribute('data-value') === value.max); - }); - - break; - case 'PropertyType': - document.querySelectorAll('.filter[name="PropertyType"] button').forEach((button) => { - button.classList.toggle('selected', value.includes(button.value)); - }); - break; - case 'Features': - if (document.querySelector('#container-tags').childElementCount === 0) { - value.forEach((key) => { - buildKeywordEl(key, removeFilterValue); - }); - } - break; - case 'YearBuilt': - [min, max] = [value.min !== '' ? value.min : 'No Min', value.max !== '' ? value.max : 'No Max']; - document.querySelectorAll('[name="YearBuilt"] .select-selected').forEach((elem, i) => { - elem.innerText = i === 0 ? min : max; - }); - break; - case 'OpenHouses': - filter = document.querySelector('[name="OpenHouses"]'); - filter.classList.toggle('selected', !!value); - filter.querySelector('input[type="checkbox"]').checked = !!value; - if (value) { - filter.querySelector(`[name="OpenHouses"] input[value="${value}"]`).checked = true; - } - break; - case 'MatchAnyFeatures': - document.querySelector('[name="matchTagsAll"]').checked = !value; - document.querySelector('[name="matchTagsAny"]').checked = value; - break; - case 'ApplicationType': - value.forEach((key) => { - document.querySelector(`[name="${name}"] .column [name="${key}"] .checkbox`).classList.add('checked'); - }); - break; - case 'Sort': - case 'Page': - // do nothing - break; - default: - document.querySelector(`.filter[name="${name}"] .checkbox`).classList.toggle('checked', value); - document.querySelector(`.filter[name="${name}"] input`).value = value; - } - }); - } -} diff --git a/blocks/property-search-bar/filters/additional-filter-buttons-delayed.js b/blocks/property-search-bar/filters/additional-filter-buttons-delayed.js deleted file mode 100644 index 0e7e34e6..00000000 --- a/blocks/property-search-bar/filters/additional-filter-buttons-delayed.js +++ /dev/null @@ -1,32 +0,0 @@ -import { togglePropertyForm } from '../common-function.js'; -import { populatePreSelectedFilters, setFilterValue, setInitialValuesFromUrl } from '../filter-processor.js'; - -const event = new Event('onFilterChange'); - -function addEventListeners() { - const block = document.querySelector('.property-search-bar.block'); - // close form on click cancel button - block.querySelector('.filter-buttons a[title="cancel"]').addEventListener('click', () => { - togglePropertyForm(); - }); - // reset form on click reset button - block.querySelector('.filter-buttons a[title="reset"]').addEventListener('click', () => { - // @todo set up initial values - setInitialValuesFromUrl(); - // layout fields - populatePreSelectedFilters(false); - // top menu - populatePreSelectedFilters(); - }); - // apply filters on click apply button - block.querySelector('.filter-buttons a[title="apply"]').addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - togglePropertyForm(); - setFilterValue('MinPrice', document.querySelector('.filter [name="MinPrice"]').value); - setFilterValue('MaxPrice', document.querySelector('.filter [name="MaxPrice"]').value); - populatePreSelectedFilters(); - window.dispatchEvent(event); - }); -} -addEventListeners(); diff --git a/blocks/property-search-bar/filters/additional-filter-buttons.js b/blocks/property-search-bar/filters/additional-filter-buttons.js deleted file mode 100644 index cf9eb666..00000000 --- a/blocks/property-search-bar/filters/additional-filter-buttons.js +++ /dev/null @@ -1,37 +0,0 @@ -function observeButtons() { - const script = document.createElement('script'); - script.id = crypto.randomUUID(); - script.type = 'text/partytown'; - script.innerHTML = ` - const script = document.createElement('script'); - script.type = 'module'; - script.src = '${window.hlx.codeBasePath}/blocks/property-search-bar/filters/additional-filter-buttons-delayed.js'; - document.head.append(script); - `; - document.head.append(script); -} - -function build() { - const buttons = ['cancel', 'reset']; - const wrapper = document.createElement('div'); - wrapper.classList.add('filter-buttons', 'button-container', 'flex-row', 'vertical-center', 'hide'); - let output = ` - - apply - `; - buttons.forEach((button) => { - output += ` - - ${button} - `; - }); - wrapper.innerHTML = output; - observeButtons(); - return wrapper; -} - -const layoutButtons = { - build, -}; - -export default layoutButtons; diff --git a/blocks/property-search-bar/filters/additional-filters.js b/blocks/property-search-bar/filters/additional-filters.js deleted file mode 100644 index 3e657747..00000000 --- a/blocks/property-search-bar/filters/additional-filters.js +++ /dev/null @@ -1,215 +0,0 @@ -import { - addRangeOption, EXTRA_FILTERS, formatInput, TOP_LEVEL_FILTERS, - getConfig, buildFilterSearchTypesElement, getFilterLabel, -} from '../common-function.js'; -import PropertyType from '../../../scripts/apis/creg/PropertyType.js'; -import OpenHouses from '../../../scripts/apis/creg/OpenHouses.js'; - -const SEARCH_TYPES = { ApplicationType: { label: 'Search Types', type: 'search-types' } }; -const FILTERS = { ...SEARCH_TYPES, ...TOP_LEVEL_FILTERS, ...EXTRA_FILTERS }; - -function observeFilters() { - const script = document.createElement('script'); - script.id = crypto.randomUUID(); - script.type = 'text/partytown'; - script.innerHTML = ` - const script = document.createElement('script'); - script.type = 'module'; - script.src = '${window.hlx.codeBasePath}/blocks/property-search-bar/filters/additional-params-delayed.js'; - document.head.append(script); - `; - document.head.append(script); -} - -function buildPropertyColumn(properties = []) { - let output = ''; - [...properties].forEach((property) => { - output += ``; - }); - return output; -} - -function buildCheckBox(ariaLabel, label = '') { - return `
    - -
    `; -} - -function buildPropertyFilterHtml(label) { - const firstColumnValues = [ - PropertyType.CONDO_TOWNHOUSE, - PropertyType.COMMERCIAL, - PropertyType.LAND, - ]; - const secondColumnValues = [ - PropertyType.SINGLE_FAMILY, - PropertyType.MULTI_FAMILY, - PropertyType.FARM, - ]; - return ` -
    -
    ${buildPropertyColumn(firstColumnValues)}
    -
    ${buildPropertyColumn(secondColumnValues)}
    -
    - ${buildCheckBox(label, 'Select All')} -`; -} - -function buildFilterOpenHouses() { - return ` -
    - ${buildCheckBox('Open Houses Only')} -
    - -
    -
    - -
    -
    -`; -} -function buildKeywordSearch() { - return ` -
    - - -
    -
    -
    -
    - -
    - -
    -
    - -
    -
    -`; -} - -function buildFilterToggle() { - return ` -
    -
    - -
    -
    -
    `; -} - -function buildSectionFilter(filterName) { - const number = getConfig(filterName); - const defaultValue = 'Any'; - const name = filterName.toLowerCase(); - let output = ` - '; - return output; -} - -function getOptions(name) { - let options = ''; - const { type } = FILTERS[name]; - switch (type) { - case 'select': - options = buildSectionFilter(name); - break; - case 'range': - options = addRangeOption(name); - break; - case 'toggle': - options = buildFilterToggle(); - break; - case 'keywords-search': - options = buildKeywordSearch(); - break; - case 'open-houses': - options = buildFilterOpenHouses(); - break; - case 'property': - options = buildPropertyFilterHtml(); - break; - case 'search-types': - options = buildFilterSearchTypesElement(); - break; - default: - break; - } - return options; -} - -function buildPlaceholder(filterName) { - const { type } = FILTERS[filterName]; - if (type === 'child') { - return ''; - } - const placeholder = document.createElement('div'); - const label = getFilterLabel(filterName); - const options = getOptions(filterName); - placeholder.setAttribute('name', filterName); - placeholder.classList.add('filter'); - placeholder.innerHTML = ` - ${options}`; - return placeholder.outerHTML; -} - -async function build() { - const wrapper = document.createElement('div'); - let output = ''; - Object.keys(FILTERS).forEach((filter) => { output += buildPlaceholder(filter); }); - wrapper.classList.add('filter-block', 'hide', 'input'); - wrapper.innerHTML = ` - ${output}`; - observeFilters(); - return wrapper; -} - -const additionalFilters = { - build, -}; - -export default additionalFilters; diff --git a/blocks/property-search-bar/filters/additional-params-delayed.js b/blocks/property-search-bar/filters/additional-params-delayed.js deleted file mode 100644 index 5b8c8c22..00000000 --- a/blocks/property-search-bar/filters/additional-params-delayed.js +++ /dev/null @@ -1,162 +0,0 @@ -import { - removeFilterValue, - setFilterValue, -} from '../filter-processor.js'; -import { buildKeywordEl, updateFilters } from '../common-function.js'; -import OpenHouses from '../../../scripts/apis/creg/OpenHouses.js'; - -const event = new Event('onFilterChange'); - -function toggleFilter(el) { - const div = el.querySelector('.checkbox'); - const name = el.closest('.filter').getAttribute('name'); - div.classList.toggle('checked'); - let value = div.classList.contains('checked'); - el.querySelector('input').value = value; - if (name === 'ApplicationType') { - value = []; - el.closest('[name="ApplicationType"]').querySelectorAll('.filter-toggle .checked').forEach((elem) => { - value.push(elem.parentElement.getAttribute('name')); - }); - value = value.join(','); - } - setFilterValue(name, value); -} - -function addEventListeners() { - const block = document.querySelector('.property-search-bar.block'); - const propertyButtons = block.querySelectorAll('[name="PropertyType"] button'); - const openHousesFilter = block.querySelector('[name="OpenHouses"]'); - const openHousesCheckbox = openHousesFilter.querySelector('input[type="checkbox"]'); - const keyWordSearchAny = block.querySelector('[name="Features"] .filter-radiobutton input[name="matchTagsAny"]'); - const keyWordSearchAll = block.querySelector('[name="Features"] .filter-radiobutton input[name="matchTagsAll"]'); - // events for filters with type toggle - block.querySelectorAll('.filter-toggle').forEach((el) => { - el.addEventListener('click', (e) => { - toggleFilter(el); - if (el.classList.contains('for-rent') || el.classList.contains('pending')) { - updateFilters(el); - } - if (el.parentNode.classList.contains('top-menu')) { - // search property if we click top level filter - e.preventDefault(); - e.stopPropagation(); - window.dispatchEvent(event); - } - }); - }); - // select all property filters on select all - block.querySelector('[name="PropertyType"] input[type="checkbox"]').addEventListener('change', () => { - const isChecked = block.querySelector('[name="PropertyType"] input[type="checkbox"]').checked; - propertyButtons.forEach((el) => { - el.classList.toggle('selected', isChecked); - }); - if (isChecked) { - setFilterValue('PropertyType', '1,2,3,5,4,6'); - } else { - removeFilterValue('PropertyType'); - } - }); - - // add logic to select property type on click - propertyButtons.forEach((el) => { - let value; - el.addEventListener('click', () => { - el.classList.toggle('selected'); - value = el.getAttribute('value'); - // eslint-disable-next-line no-unused-expressions - el.classList.contains('selected') ? setFilterValue('PropertyType', value) : removeFilterValue('PropertyType', value); - }); - }); - - openHousesCheckbox.addEventListener('change', () => { - openHousesFilter.classList.toggle('selected'); - if (!openHousesCheckbox.checked) { - removeFilterValue('OpenHouses'); - } else if - (openHousesFilter.querySelector('input[type="radio"]:checked')) { - setFilterValue('OpenHouses', openHousesFilter.querySelector('input[type="radio"]:checked').getAttribute('value')); - } - }); - block.querySelectorAll('[name="OpenHouses"] input[type="radio"]').forEach((el) => { - el.addEventListener('change', () => { - if (el.checked) { - setFilterValue('OpenHouses', el.getAttribute('value')); - } - if (el.getAttribute('value') === '7') { - block.querySelector(`[name="OpenHouses"] input[value="${OpenHouses.ANYTIME.value}"]`).checked = false; - } else { - block.querySelector(`[name="OpenHouses"] input[value="${OpenHouses.ONLY_WEEKEND.value}"]`).checked = false; - } - }); - }); - block.querySelectorAll('#container-tags .close').forEach((el) => { - el.addEventListener('click', (e) => { - e.target.parentNode.remove(); - }); - }); - keyWordSearchAny.addEventListener('change', () => { - if (keyWordSearchAny.checked) { - keyWordSearchAll.checked = false; - setFilterValue('MatchAnyFeatures', true); - } - }); - - keyWordSearchAll.addEventListener('change', () => { - if (keyWordSearchAll.checked) { - keyWordSearchAny.checked = false; - removeFilterValue('MatchAnyFeatures'); - } - }); - // add key words to search - block.querySelector('[name="Features"] .btn').addEventListener('click', () => { - const keyword = block.querySelector('[name="Features"] input[type="text"]').value; - if (keyword) { - buildKeywordEl(keyword, removeFilterValue); - setFilterValue('Features', keyword.trim()); - } - }); - // year, square feet, sort input logic on additional filters - block.querySelectorAll('.filter .select-item .tooltip-container').forEach((element) => { - element.addEventListener('click', (e) => { - const selectedElValue = element.innerText; - const container = element.closest('section'); - const filter = element.closest('.filter'); - let name = filter.getAttribute('name'); - let value = element.getAttribute('data-value'); - container.querySelector('.highlighted').classList.remove('highlighted'); - element.classList.toggle('highlighted'); - const headerTitle = container.querySelector('.select-selected'); - if (name === 'Sort') { - headerTitle.innerText = selectedElValue; - } else { - headerTitle.innerHTML = `${selectedElValue}`; - } - if (filter.querySelector('.multiple-inputs')) { - if (name !== 'YearBuilt') { - name = element.closest('section > div').querySelector('.select-selected').getAttribute('name'); - } - if (name === 'YearBuilt') { - const values = element.closest('.multiple-inputs').querySelectorAll('.select-selected'); - if (values[0].innerText === 'No Min' && values[1].innerText === 'No Max') { - removeFilterValue('YearBuilt'); - return; - } - const minYear = values[0].innerText === 'No Min' ? 1899 : values[0].innerText; - const maxYear = values[1].innerText === 'No Max' ? 2100 : values[1].innerText; - value = `${minYear}-${maxYear}`; - } - element.closest('.select-item').classList.remove('show'); - } - setFilterValue(name, value); - if (name === 'Sort') { - e.stopPropagation(); - e.preventDefault(); - window.dispatchEvent(event); - } - element.closest('.select-item').classList.remove('show'); - }); - }); -} - -addEventListeners(); diff --git a/blocks/property-search-bar/filters/top-delayed.js b/blocks/property-search-bar/filters/top-delayed.js deleted file mode 100644 index 8973e943..00000000 --- a/blocks/property-search-bar/filters/top-delayed.js +++ /dev/null @@ -1,232 +0,0 @@ -import { abortSuggestions, getSuggestions } from '../../../scripts/apis/creg/creg.js'; -import { getAttributes, setSearchParams } from '../search/suggestion.js'; -import { - populatePreSelectedFilters, - setFilterValue, -} from '../filter-processor.js'; -import { - formatPriceLabel, closeTopLevelFilters, togglePropertyForm, hideFilter, -} from '../common-function.js'; -import { getPropertiesCount } from '../../../scripts/search/results.js'; - -const event = new Event('onFilterChange'); - -const MORE_INPUT_NEEDED = 'Please enter at least 3 characters.'; -const NO_SUGGESTIONS = 'No suggestions found. Please modify your search.'; -const SEARCHING_SUGGESTIONS = 'Looking up suggestions...'; - -function showFilter(element) { - element.classList.add('open'); - element.querySelector('.search-results-dropdown').classList.remove('hide'); -} - -const updateSuggestions = (suggestions, target) => { - // Keep the first item - required character entry count. - const first = target.querySelector(':scope li'); - target.replaceChildren(first, ...suggestions); -}; - -const createPriceList = (d) => { - let optionlist = ''; - const k = [10, 100, 1E3, 1E4, 1E5, 1E6]; - // eslint-disable-next-line no-plusplus - if (d) for (let m = 1; m <= 6; m++) optionlist += ``; - return optionlist; -}; - -function addChangeHandler(filter) { - let value; - filter.forEach((el) => { - el.addEventListener('change', () => { - if (el.checked) { - filter.forEach((input) => { - if (input.id !== el.id) input.checked = false; - }); - value = el.value === 'Any' ? '' : el.value; - setFilterValue(el.closest('.filter').getAttribute('name'), value); - } - }); - }); -} - -const buildSuggestions = (suggestions) => { - const lists = []; - let attr; - suggestions.forEach((category) => { - const list = document.createElement('li'); - list.classList.add('list-title'); - list.textContent = category.displayText; - lists.push(list); - const ul = document.createElement('ul'); - list.append(ul); - category.results.forEach((result) => { - const li = document.createElement('li'); - attr = getAttributes(result); - Object.keys(attr).forEach((key) => { - li.setAttribute(key, attr[key]); - }); - li.textContent = result.SearchParameter; - ul.append(li); - }); - }); - - return lists; -}; - -/** - * Handles the input changed event for the text field. Will add suggestions based on user input. - * - * @param {Event} e the change event - * @param {HTMLElement} target the container in which to add suggestions - */ -const inputChanged = (e, target) => { - const { value } = e.currentTarget; - if (value.length > 0) { - e.currentTarget.closest('.search-bar').classList.add('show-suggestions'); - } else { - e.currentTarget.closest('.search-bar').classList.remove('show-suggestions'); - } - - if (value.length <= 2) { - abortSuggestions(); - target.querySelector(':scope > li:first-of-type').textContent = MORE_INPUT_NEEDED; - updateSuggestions([], target); - } else { - target.querySelector(':scope > li:first-of-type').textContent = SEARCHING_SUGGESTIONS; - getSuggestions(value) - .then((suggestions) => { - if (!suggestions) { - // Undefined suggestions means it was aborted, more input coming. - updateSuggestions([], target); - return; - } - if (suggestions.length) { - updateSuggestions(buildSuggestions(suggestions), target); - } else { - target.querySelector(':scope > li:first-of-type').textContent = NO_SUGGESTIONS; - } - }); - } -}; - -const suggestionSelected = (e, block) => { - e.stopPropagation(); - e.preventDefault(); - const searchParameter = e.target.getAttribute('search-parameter'); - const keyword = e.target.getAttribute('search-input'); - if (!searchParameter) { - return; - } - setSearchParams(e.target); - block.querySelector('input[name="keyword"]').value = keyword; - block.querySelector('.search-bar').classList.remove('show-suggestions'); - - window.dispatchEvent(event); -}; - -function addEventListeners() { - const block = document.querySelector('.property-search-bar.block'); - const priceRangeInputs = block.querySelector('.container-item[name="Price"] .multiple-inputs'); - - // update top menu input placeholder on click - block.querySelectorAll('.container-item .select-item .tooltip-container').forEach((element) => { - element.addEventListener('click', () => { - let selectedElValue = element.innerText; - const value = element.getAttribute('data-value'); - const container = element.closest('.container-item'); - let name = container.getAttribute('name'); - container.querySelector('.highlighted').classList.remove('highlighted'); - element.classList.toggle('highlighted'); - const headerTitle = container.querySelector('.header .title'); - if (container.querySelector('.multiple-inputs')) { - // logic - element.closest('section > div').querySelector('.select-selected').innerHTML = selectedElValue; - name = element.closest('section > div').querySelector('.select-selected').getAttribute('name'); - const headerItems = container.querySelectorAll('.multiple-inputs .select-selected'); - const fromSelectedValue = headerItems[0].innerText; - const toSelectedValue = headerItems[1].innerText; - if (fromSelectedValue === 'No Min' && toSelectedValue === 'No Max') { - selectedElValue = 'square feet'; - } else { - selectedElValue = `${fromSelectedValue}-${toSelectedValue}`; - } - element.closest('.select-item').classList.remove('show'); - } else { - hideFilter(container); - } - setFilterValue(name, value); - headerTitle.innerHTML = `${selectedElValue}`; - window.dispatchEvent(event); - }); - }); - - // add logic on price range change - priceRangeInputs.addEventListener('keyup', (e) => { - const minPrice = priceRangeInputs.querySelector('[name="MinPrice"]').value; - const maxPrice = priceRangeInputs.querySelector('[name="MaxPrice"]').value; - // display datalist - const activeElement = e.target.closest('.price-range-input'); - const name = activeElement.getAttribute('name'); - const { value } = activeElement; - activeElement.list.innerHTML = createPriceList(activeElement.value); - - // update label - block.querySelector('[name="Price"] .title > span').innerText = formatPriceLabel(minPrice, maxPrice); - setFilterValue(name, value); - window.dispatchEvent(event); - }); - - block.querySelectorAll('.container-item .header').forEach((selectedFilter) => { - selectedFilter.addEventListener('click', () => { - const isOpened = selectedFilter.parentElement.classList.contains('open'); - closeTopLevelFilters(); - if (!isOpened) { - showFilter(selectedFilter.parentElement); - } - }); - }); - // baths and beds - addChangeHandler(block.querySelectorAll('[name="MinBathroomsTotal"] input')); - addChangeHandler(block.querySelectorAll('[name="MinBedroomsTotal"] input')); - - // open additional filters - block.querySelector('.filter-container').addEventListener('click', () => { - togglePropertyForm(); - const overlay = document.querySelector('.property-search-bar.block .overlay'); - const toggledOnClose = overlay.classList.contains('hide'); - closeTopLevelFilters(false); - if (toggledOnClose) { - setFilterValue('MinPrice', document.querySelector('.filter [name="MinPrice"]').value); - setFilterValue('MaxPrice', document.querySelector('.filter [name="MaxPrice"]').value); - } - populatePreSelectedFilters(toggledOnClose); - }); - - block.querySelectorAll('.select-selected').forEach((el) => { - let isOpened; - el.addEventListener('click', () => { - if (el.closest('.multiple-inputs').getAttribute('name') === 'Sort') { - isOpened = document.querySelector('[name="Sort"] .select-item').classList.contains('show'); - closeTopLevelFilters(); - if (isOpened) { - document.querySelector('[name="Sort"] .select-item').classList.add('show'); - } - } - el.closest('section > div').querySelector('.select-item').classList.toggle('show'); - }); - }); - // suggestions - const suggestionsTarget = block.querySelector('.suggester-input .suggester-results'); - block.querySelector('.search-listing-block [name="keyword"]').addEventListener('input', (e) => { - inputChanged(e, suggestionsTarget); - }); - suggestionsTarget.addEventListener('click', (e) => { - suggestionSelected(e, block); - }); - window.addEventListener('onResultUpdated', () => { - const count = getPropertiesCount(); - block.querySelector('.total-results > div').textContent = `Showing ${count} of ${count} Properties`; - }); -} - -addEventListeners(); diff --git a/blocks/property-search-bar/filters/top-menu.js b/blocks/property-search-bar/filters/top-menu.js deleted file mode 100644 index 6ee162a1..00000000 --- a/blocks/property-search-bar/filters/top-menu.js +++ /dev/null @@ -1,168 +0,0 @@ -import { - getPlaceholder, - addRangeOption, - addOptions, - TOP_LEVEL_FILTERS, - getConfig, - processSearchType, - getFilterLabel, -} from '../common-function.js'; - -function observeFilters() { - const script = document.createElement('script'); - script.id = crypto.randomUUID(); - script.type = 'text/partytown'; - script.innerHTML = ` - const script = document.createElement('script'); - script.type = 'module'; - script.src = '${window.hlx.codeBasePath}/blocks/property-search-bar/filters/top-delayed.js'; - document.head.append(script); - `; - document.head.append(script); -} - -function buildTotalResults() { - const wrapper = document.createElement('div'); - wrapper.classList.add('total-results'); - wrapper.innerHTML = '
    '; - return wrapper; -} - -function buildMapToggle() { - const wrapper = document.createElement('div'); - wrapper.classList.add('map-toggle', 'flex-row', 'center'); - wrapper.innerHTML = ` - - - grid view - `; - return wrapper; -} - -function buildButton(label, primary = false) { - const button = document.createElement('div'); - button.classList.add('button-container'); - button.innerHTML = ` - - ${label} - `; - return button; -} - -function buildFilterToggle() { - const wrapper = document.createElement('div'); - wrapper.setAttribute('name', 'AdditionalFilters'); - wrapper.classList.add('filter-container', 'flex-row', 'center', 'bl'); - wrapper.innerHTML = ` - - - - - - - `; - return wrapper; -} - -function buildSortByEl() { - const filterName = 'Sort'; - const label = getFilterLabel(filterName); - const defaultValue = 'Price (Hi-Lo)'; - const options = addOptions(filterName, defaultValue, 'multi', filterName); - const dropdownContainer = document.createElement('div'); - dropdownContainer.classList.add('flex-row', 'multiple-inputs', 'filter'); - dropdownContainer.setAttribute('name', filterName); - dropdownContainer.innerHTML = `
    -
    ${label}
    -
    -
    ${options}
    `; - dropdownContainer.querySelector('.select-selected').classList.add('text-up'); - dropdownContainer.querySelectorAll('.select-item li').forEach((el) => { - el.classList.add('text-up'); - if (el.getAttribute('data-value') === '') { - el.remove(); - } - if (el.innerText === defaultValue) { - el.classList.add('highlighted'); - } - }); - return dropdownContainer; -} - -function buildTopFilterPlaceholder(filterName) { - const dropdownContainer = document.createElement('div'); - const { type } = TOP_LEVEL_FILTERS[filterName]; - let label = getFilterLabel(filterName); - let options = addRangeOption(filterName); - if (type === 'select') { - options = addOptions(filterName, `Any ${label}`); - label = `Any ${label}`; - } - dropdownContainer.classList.add('bl', 'container-item'); - dropdownContainer.setAttribute('name', filterName); - dropdownContainer.innerHTML = `
    -
    ${label}
    -
    -
    ${options}
    `; - - return dropdownContainer; -} -function buildFilterSearchTypesElement() { - const wrapper = document.createElement('div'); - let el; - wrapper.classList.add('filter', 'flex-row', 'center', 'top-menu'); - wrapper.setAttribute('name', 'ApplicationType'); - getConfig('ApplicationType').forEach((value) => { - el = processSearchType(value); - el.classList.add('center', 'ml-1'); - el.querySelector('label').classList.add('fs-xs'); - wrapper.append(el); - }); - return wrapper; -} - -async function build() { - const wrapper = document.createElement('div'); - const container = document.createElement('div'); - const div = document.createElement('div'); - const bfContainer = document.createElement('div'); - const bfRightSection = document.createElement('div'); - const filterContainer = document.createElement('div'); - filterContainer.classList.add('result-filters', 'flex-row'); - bfRightSection.classList.add('flex-row', 'space-between'); - bfContainer.classList.add('bf-container'); - container.classList.add('search-listing-container', 'flex-row'); - wrapper.classList.add('search-listing-block'); - - const primaryFilters = document.createElement('div'); - primaryFilters.classList.add('primary-search', 'flex-row'); - primaryFilters.innerHTML = ` `; - wrapper.prepend(primaryFilters, buildButton('Search', true)); - Object.keys(TOP_LEVEL_FILTERS).forEach((filter) => { - const filterElement = buildTopFilterPlaceholder(filter); - wrapper.append(filterElement); - }); - wrapper.append(buildFilterToggle(), buildButton('save search', true)); - div.append(wrapper); - bfRightSection.append(buildSortByEl(), buildMapToggle()); - bfContainer.append(buildFilterSearchTypesElement(), bfRightSection); - filterContainer.append(buildTotalResults(), bfContainer); - container.append(div, filterContainer); - observeFilters(); - return container; -} - -const topMenu = { - build, -}; - -export default topMenu; diff --git a/blocks/property-search-bar/property-search-bar.css b/blocks/property-search-bar/property-search-bar.css index 30f82d71..d00c5d1b 100644 --- a/blocks/property-search-bar/property-search-bar.css +++ b/blocks/property-search-bar/property-search-bar.css @@ -1,1017 +1,958 @@ -@import url('./search-results-dropdown.css'); +/* stylelint-disable no-descending-specificity */ +/** Override global settings **/ main .section.property-search-bar-container { - max-width: 100vw; - padding: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.property-search-template .section.property-search-bar-container { - margin-bottom: 0; - position: sticky; - top: 65px; - z-index: 10; - background: white; + max-width: 100vw; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + margin: 0; } -.property-search-template header { - position: sticky; - top: 0; - background: white; - z-index: 11; +main .section.property-search-bar-container .property-search-bar-wrapper { + padding: 0; } -.property-search-template footer { - background-color: var(--light-grey); - border-top: 1px solid var(--platinum); - position: relative; - z-index: 4; -} +/** End overrides **/ .property-search-bar.block { - width: 100vw; + width: 100vw; + background-color: var(--primary-color); } -main .section .property-search-bar .mb-1 { - margin-bottom: 1em; +.property-search-bar.block .search-form-wrapper form { + position: relative; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + height: 50px; + padding: 0 15px; + margin: 0 auto; + max-width: 1500px; } -main .section .property-search-bar .mt-1 { - margin-top: 1em; +.property-search-bar.block .search-form-wrapper span.icon { + height: 20px; + width: 20px; } -main .section .property-search-bar .ml-1 { - margin-left: 1em; +.property-search-bar.block .search-form-wrapper span.icon > img { + height: 100%; + width: 100%; + filter: brightness(0) invert(1); } -main .section .property-search-bar .mr-1 { - margin-right: 1em; +.property-search-bar.block form .search-bar { + position: relative; + flex-grow: 1; + max-width: 550px; } -main .section .property-search-bar .fs-1 { - font-size: var(--body-font-size-s) +.property-search-bar.block .search-bar input[name="keyword"] { + padding: 10px; + border: 1px solid var(--grey); + height: 36px; + width: 100%; + background-color: rgba(194 153 175 / 30%); + color: var(--primary-light); + letter-spacing: normal; + font-size: var(--body-font-size-s); } -main .section .property-search-bar .c-w { - color:var(--white); +.property-search-bar.block .search-bar input[name="keyword"]::placeholder { + color: var(--primary-light); } -main .section .property-search-bar .center { - text-align: center; +.property-search-bar.block .search-bar input[name="keyword"]:focus { + color: var(--body-color); + background-color: var(--white); } -main .section .property-search-bar .bl { - border-left: 1px solid var(--black); +.property-search-bar.block form .result-filters { + display: flex; + align-items: center; } -main .section .property-search-bar .fs-xs { - font-size: var(--body-font-size-xs); +.property-search-bar.block form .result-filters .selected span { + color: var(--white); } -main .section .property-search-bar .flex-column { - display: flex; - flex-direction: column; -} -main .section .property-search-bar .flex-row { - display: flex; - flex-direction: row; +.property-search-bar.block .result-filters .range-wrapper, +.property-search-bar.block .result-filters .select-wrapper { + display: none; + position: relative; + align-items: center; + margin: 0; + padding: 0 15px; + height: 50px; + text-overflow: ellipsis; + border-left: 1px solid var(--black); } -main .section .property-search-bar .flex-row.center { - justify-content: center; - align-items: center +.property-search-bar.block .result-filters .range-wrapper:last-of-type { + border-right: 1px solid var(--black); } -main .section .property-search-bar .flex-row.vertical-center { - align-items: center; -} -main .section .property-search-bar .flex-row.space-between { - justify-content: space-between; +.property-search-bar.block .result-filters .range-wrapper .select-wrapper { + border: none; + padding: 0; + height: 45px; } -.property-search-bar.block .search-listing-container { - justify-content: flex-start; - flex-wrap: wrap; - +.property-search-bar.block .result-filters .select-wrapper select, +.property-search-bar.block .advanced-filters .misc .select-wrapper select { + display: none; } -.property-search-bar.block .total-results { - flex-basis: 100%; - max-width: var(--normal-page-width); - padding: 20px 0 0 30px; - color: var(--body-color); - font-size: var(--body-font-size-s); +.property-search-bar.block .result-filters .range-wrapper > .selected, +.property-search-bar.block .result-filters .select-wrapper > .selected { + display: flex; + position: relative; + align-items: center; + justify-content: space-between; + width: 100%; + height: 100%; + min-width: 100px; + line-height: 40px; + color: var(--white); } -main .section .property-search-bar .overlay { - position: absolute; - overflow-y: auto; - left: 0; - top: 50px; - width: 100vw; - height: 100vh; - background-color: #212529; - opacity: .5; - z-index: 998; +.property-search-bar.block .result-filters .range-wrapper .select-wrapper > .selected { + padding: 0 15px; + width: 140px; + height: 45px; + color: var(--body-color); + border: 1px solid var(--platinum); } -main .section .property-search-bar .hide { - display: none; +.property-search-bar.block .result-filters .range-wrapper > .selected span, +.property-search-bar.block .result-filters .select-wrapper > .selected span { + display: block; + font-size: var(--body-font-size-xs); + font-weight: var(--font-weight-semibold); + letter-spacing: unset; + line-height: normal; + text-transform: uppercase; } -main .section .property-search-bar .button-container { - margin: 0 15px 0 8px; +.property-search-bar.block .result-filters .range-wrapper .select-wrapper > .selected span { + text-transform: none; } -.property-search-bar.block .container-item *, -.property-search-bar.block .filter-block * , -.property-search-bar.block .filter-buttons * { - font-size: var(--body-font-size-s); +.property-search-bar.block .result-filters .range-wrapper > .selected::after, +.property-search-bar.block .result-filters .select-wrapper > .selected::after { + display: block; + content: '\f0d7'; + font-family: var(--font-family-fontawesome); + text-align: right; + width: 25px; } -.property-search-bar.block .search-listing-block .button-container * { - font-size: var(--body-font-size-xs); +.property-search-bar.block .result-filters .range.open > .selected::after, +.property-search-bar.block .result-filters .select-wrapper.open > .selected::after { + content: '\f0d8'; } -.property-search-bar.block .filter-container li, -.property-search-bar.block .filter-container input, -.property-search-bar.block .filter-container label, -.property-search-bar.block .filter-container a, -.property-search-bar.block .filter-container svg{ - cursor: pointer; +.property-search-bar.block .result-filters .range-wrapper .range-items, +.property-search-bar.block .result-filters .select-wrapper .select-items { + display: none; + position: absolute; + top: 100%; + left: 0; + padding: 10px; + background-color: var(--white); + z-index: 501; + box-shadow: 0 .5rem 1rem rgba(0 0 0 / 15%); } -.property-search-bar.block .result-filters { - flex-wrap: wrap; - width: 100%; +.property-search-bar.block .result-filters .range-wrapper .select-wrapper .select-items { + padding: 0; + max-height: 185px; + overflow-y: scroll; + width: 100%; + border: 1px solid var(--platinum); } -.property-search-bar.block .bf-container { - display: flex; - flex-basis: 100%; - max-width: var(--normal-page-width); - justify-content: flex-end; +.property-search-bar.block .result-filters .range-wrapper:first-of-type .range-items { + left: 0; + right: unset; } -.property-search-bar.block .result-filters >div:last-of-type { - width: 100%; +.property-search-bar.block .result-filters .range-wrapper .range-items { + left: unset; + right: 0; + color: var(--body-color); } -.property-search-bar.block .filter-container a { - display: block; - width: 35px; - height: 35px; - position: relative; +.property-search-bar.block .result-filters .select-wrapper.open .select-items { + display: block; } -main .section .property-search-bar .button-container a { - text-transform: uppercase; +.property-search-bar.block .result-filters .select-wrapper .select-items li { + display: flex; + font-size: var(--body-font-size-s); + padding: 4px 15px; + line-height: 30px; + cursor: pointer; + border-left: 1px solid var(--platinum); + border-right: 1px solid var(--platinum); } -.property-search-bar.block .property-search-bar-wrapper { - width: 100%; +.property-search-bar.block .result-filters .range-wrapper .select-wrapper .select-items li { + border: none; + padding: 4px 8px; } -.property-search-bar.block .search-listing-block { - padding: 0 15px; - text-align: center; - display: flex; - height: 50px; - width: 100vw; - background-color: var(--primary-color); - color: var(--white); - align-items: center; - cursor: pointer; - justify-content: flex-start; +.property-search-bar.block .result-filters > .select-wrapper .select-items li:first-of-type { + border-top: 1px solid var(--platinum); } -.property-search-bar.block .search-listing-block span{ - color: var(--white); +.property-search-bar.block .result-filters > .select-wrapper .select-items li:last-of-type { + border-bottom: 1px solid var(--platinum); } -.property-search-bar.block .search-listing-container > div:first-of-type { - width: 100%; - justify-content: flex-start; +.property-search-bar.block .result-filters .select-wrapper .select-items li:hover { + color: var(--body-color); + background-color: var(--light-grey); } -.property-search-bar.block [name="ApplicationType"] { - padding: 0 20px; +.property-search-bar.block .result-filters .select-wrapper .select-items li.selected { + color: var(--body-color); + background-color: var(--light-grey); } -.property-search-bar.block .result-filters [name="ApplicationType"] { - display: none +.property-search-bar.block .result-filters .range-wrapper.open .range-items { + display: flex; + align-items: center; } -.property-search-bar.block .result-filters .map-toggle { - display: none +.property-search-bar.block .result-filters .range-wrapper .range-items .input-dropdown select { + display: none; + visibility: hidden; } -.property-search-bar.block .filter { - padding: 20px; +.property-search-bar.block .result-filters .range-wrapper .range-items .input-dropdown input { + padding: 10px; + height: 45px; + width: 115px; + font-size: var(--body-font-size-s); + letter-spacing: normal; + border: 1px solid var(--platinum); } -.property-search-bar.block .container-item .header { - position: relative; - display: flex; - justify-content: center; - +.property-search-bar.block .result-filters .range-wrapper .range-items > span { + text-align: center; + margin: 0 16px; + text-transform: uppercase; + font-weight: var(--font-weight-light); } -main .section .property-search-bar .title { - font-size: var(--body-font-size-s); - white-space: pre; - line-height: 50px; - position: relative; -} - -/* arrow near dropdown start */ -main .section .property-search-bar .title::after { - display: inline; - position: absolute; - right: -20px; - width: 10px; - content: '\f0d7'; - font-family: var(--font-family-fontawesome); - height: unset; - color: var(--white); +.property-search-bar.block .result-filters a.filter { + display: flex; + margin: 0 8px; + height: 35px; + width: 35px; + align-items: center; + justify-content: center; } -main .section .property-search-bar .open .title::after { - content: '\f0d8'; +.property-search-bar.block .result-filters a.filter span.icon-close-x-white, +.property-search-bar.block .result-filters a.filter.open span.icon-filter-white { + display: none; } -/* end */ - -.property-search-bar.block .filter-buttons { - padding: 15px; - justify-content: center; - box-shadow: 0 0 4px 0 rgb(0 0 0 / 50%); - width: 100vw; - position: fixed; - bottom: 0; - left: 0; - background-color: var(--white); - height: 64px; - margin: 0; - z-index: 1000; +.property-search-bar.block .result-filters a.filter span.icon-filter-white, +.property-search-bar.block .result-filters a.filter.open span.icon-close-x-white { + display: block; } -.property-search-bar.block .btn-map-toggle > span { - display: block; - font-size: var(--body-font-size-xs); - height: 35px; - background: var(--white); - color: var(--body-color); - border: 1px solid var(--grey); - padding: 0 10px; - line-height: 35px; +.property-search-bar.block form a.save-search { + display: none; } -main .section .property-search-bar .btn.btn-secondary { - background: #fff; - color: var(--black); - border: 1px solid var(--black); -} -main .section .property-search-bar .btn.btn-primary { - background: var(--primary-color); - color: var(--white); - border: 1px solid var(--grey); - min-width: 70px; - padding: 10px; - font-size: var(--body-font-size-xs); - white-space: nowrap; +.property-search-bar.block form a.search-submit { + height: 36px; + width: 36px; + padding: 7px; + color: var(--body-color); + border: 1px solid var(--grey); + background-color: transparent; } -main .section .property-search-bar .filter-buttons .btn, -.property-search-bar.block [name="Features"] button{ - padding: 7px 25px; - margin-right: 10px; - vertical-align: middle; +.property-search-bar.block .advanced-filters { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + height: calc(100vh - var(--nav-height) - 50px); + overflow-y: scroll; + z-index: 500; + background-color: var(--white); } -main .section .property-search-bar .title span { - position: relative; - letter-spacing: var(--letter-spacing-s); +.property-search-bar.block .advanced-filters.open { + display: block; } -main .section .property-search-bar .btn.btn-secondary:hover { - color: var(--black); - border: 1px solid var(--grey); - box-shadow: none; +.property-search-bar.block .advanced-filters > div { + padding: 20px; + border-bottom: 1px solid var(--grey); } -.property-search-bar.block [name="Features"] button { - background: var(--white); - border: 1px solid var(--grey); +.property-search-bar.block .advanced-filters .listing-types { + display: flex; + flex-wrap: wrap; } -.property-search-bar.block [name="Sort"] .title span { - font-size: var(--body-font-size-s); - letter-spacing: normal; +.property-search-bar.block .advanced-filters .listing-types > label { + flex-basis: 100%; } -.property-search-bar.block .filter-buttons .btn-primary:hover, -.property-search-bar.block .filter-buttons .btn-primary span:hover { - background-color: var(--white); - color: var(--body-color); +.property-search-bar.block .advanced-filters .listing-types > div { + flex-basis: 50%; } -.property-search-bar.block .input-container input { - padding: 10px; - border: 1px solid var(--grey); - letter-spacing: normal; - height: 36px; - background-color: rgb(241 241 241 / 10%); - width: 100%; -} -.property-search-bar.block .input-dropdown input { - width: 100%; - height: 55px; - padding: 10px; - border: 1px solid var(--grey); - color: var(--black); +.property-search-bar.block .advanced-filters .attributes > div:not(:last-of-type) { + margin-bottom: 1.5rem; } -.property-search-bar.block .filter-block input, -.property-search-bar.block .filter-block input::placeholder, -.property-search-bar.block .filter-block .select-selected { - font-weight: 300; - font-size: var(--body-font-size-m); - color: var(--input-placeholder); - letter-spacing: normal; +.property-search-bar.block .advanced-filters .select-wrapper { + position: relative; } -.property-search-bar.block input:focus { - background: var(--white); - color: var(--body-color); - outline: none; +.property-search-bar.block .advanced-filters .select-wrapper > .selected { + display: flex; + position: relative; + align-items: center; + justify-content: space-between; + width: 100%; + height: 100%; + min-width: 100px; + line-height: 40px; + color: var(--white); } -.property-search-bar.block .search-bar input:first-of-type { - padding: 10px; - border: 1px solid var(--grey); - letter-spacing: normal; - height: 36px; - background-color: rgb(241 241 241 / 10%); - width: 100%; - color: var(--white); +.property-search-bar.block .advanced-filters .attributes .select-wrapper > .selected, +.property-search-bar.block .advanced-filters .misc .select-wrapper > .selected { + padding: 0 15px; + height: 45px; + color: var(--body-color); + border: 1px solid var(--platinum); } -.property-search-bar.block .filter .multiple-inputs .input-dropdown, -.property-search-bar.block .filter .multiple-inputs section{ - flex: 0 0 41.6667%; - max-width: 41.6667%; +.property-search-bar.block .advanced-filters .attributes .select-wrapper > .selected span, +.property-search-bar.block .advanced-filters .misc .select-wrapper > .selected span { + overflow: hidden; + font-size: var(--body-font-size-s); + font-weight: var(--font-weight-light); + line-height: var(--line-height-xs); } -.property-search-bar.block .multiple-inputs .range-label { - margin: 0 1rem; - color: var(--body-color); - font-weight: 300; - font-size: 15px; - letter-spacing: var(--letter-spacing-xxs); +.property-search-bar.block .advanced-filters .select-wrapper > .selected::after { + display: block; + content: '\f0d7'; + font-family: var(--font-family-fontawesome); + text-align: right; + width: 15px; } -.property-search-bar.block .filter .multiple-inputs .range-label { - flex: 0 0 16.6667%; - max-width: 16.6667%; - text-align: center; +.property-search-bar.block .advanced-filters .select-wrapper.open > .selected::after { + content: '\f0d8'; } -.property-search-bar.block .filter .multiple-inputs .input-dropdown input { - width: 100% +.property-search-bar.block .advanced-filters .select-wrapper .select-items { + display: none; + position: absolute; + top: 100%; + left: 0; + padding: 10px; + background-color: var(--white); + z-index: 501; + box-shadow: 0 .5rem 1rem rgba(0 0 0 / 15%); } -.property-search-bar.block .filter-checkbox .checkbox { - cursor: pointer; - height: 24px; - width: 24px; - border: 1px solid var(--grey); - margin-right: 0.5rem; - position: relative; +.property-search-bar.block .advanced-filters .attributes .select-wrapper .select-items, +.property-search-bar.block .advanced-filters .misc .select-wrapper .select-items { + padding: 0; + max-height: 185px; + overflow-y: scroll; + width: 100%; + border: 1px solid var(--platinum); } -.property-search-bar.block .filter .filter-toggle .checkbox { - width: 24px; - height: 16px; - border-radius: 100px; - position: relative; - border: 1px solid #b4b4b4; - background: var(--white); +.property-search-bar.block .advanced-filters .select-wrapper.open .select-items { + display: block; } -.property-search-bar.block .filter .filter-toggle .checkbox::before { - content: ''; - position: absolute; - right: 2px; - top: 2px; - height: 10px; - width: 10px; - border-radius: 10px; - z-index: 1; - background: #b4b4b4; +.property-search-bar.block .advanced-filters .select-wrapper .select-items li { + display: flex; + font-size: var(--body-font-size-s); + padding: 4px 15px; + line-height: 30px; + cursor: pointer; + border-left: 1px solid var(--platinum); + border-right: 1px solid var(--platinum); } -.property-search-bar.block .filter .filter-toggle .checkbox.checked { - background: var(--body-color); - border: 1px solid transparent +.property-search-bar.block .advanced-filters .attributes .select-wrapper .select-items li, +.property-search-bar.block .advanced-filters .misc .select-wrapper .select-items li { + border: none; + padding: 4px 8px; } -.property-search-bar.block .filter .filter-toggle .checkbox.checked::before{ - transform: translateX(-8px); - background: var(--white); +.property-search-bar.block .advanced-filters .select-wrapper .select-items li:hover { + color: var(--body-color); + background-color: var(--light-grey); } -.property-search-bar.block .filter .filter-toggle.disabled { - pointer-events: none; - opacity: .3; +.property-search-bar.block .advanced-filters .select-wrapper .select-items li.selected{ + color: var(--body-color); + background-color: var(--light-grey); } -.property-search-bar.block .container { - margin-right: 0.5rem; - margin-left: 0.5rem; - padding: 0 15px; +.property-search-bar.block .advanced-filters .attributes .price .range-items, +.property-search-bar.block .advanced-filters .attributes .sqft .range-items, +.property-search-bar.block .advanced-filters .misc .year-range .range-items { + display: flex; + align-items: center; + color: var(--body-color); } -.property-search-bar.block .container-item { - margin: 0 0.5rem; - order: 3; - padding: 0 15px; +.property-search-bar.block .advanced-filters .attributes .sqft .range-items > *, +.property-search-bar.block .advanced-filters .misc .year-range .range-items > * { + flex: 1 1 auto; } -.property-search-bar.block .primary-search { - width: 550px; - order: 1; +.property-search-bar.block .advanced-filters .attributes .range-items .select-wrapper select, +.property-search-bar.block .advanced-filters .year-range .range-items .select-wrapper select { + display: none; + visibility: hidden; } -.property-search-bar.block .square-feet { - border-right: 1px solid var(--black); - padding-right: 30px; +.property-search-bar.block .advanced-filters .attributes .price .range-items > span, +.property-search-bar.block .advanced-filters .attributes .sqft .range-items > span, +.property-search-bar.block .advanced-filters .misc .year-range .range-items > span { + text-align: center; + margin: 0 16px; + text-transform: uppercase; + font-weight: var(--font-weight-light); } -.property-search-bar.block .filter-container { - width: 35px; - order: 2; + +.property-search-bar.block .advanced-filters .attributes .price .range-items .input-dropdown { + flex: 1 1 auto; } -.property-search-bar.block .filter-container a svg { - width: 21px; - height: 20px; - position: absolute; - top: calc(50% - 10px); - left: calc(50% - 10px); +.property-search-bar.block .advanced-filters .attributes .price .range-items .input-dropdown input { + padding: 10px; + height: 50px; + width: 100%; + font-size: var(--body-font-size-s); + letter-spacing: normal; + border: 1px solid var(--platinum); } -.property-search-bar.block .label-image { - height: auto; - width: 25px; - margin-right: 5px; - padding: 5px; +.property-search-bar.block .advanced-filters .attributes .bedrooms ul, +.property-search-bar.block .advanced-filters .attributes .bathrooms ul { + display: flex; + align-items: center; } -.property-search-bar.block .search-bar { - flex-grow: 1; - position: relative; +.property-search-bar.block .advanced-filters .attributes .bedrooms ul li, +.property-search-bar.block .advanced-filters .attributes .bathrooms ul li { + flex: 1; + border: 1px solid var(--platinum); + border-right: none; } -.property-search-bar.block .input-container { - flex-grow: 1; +.property-search-bar.block .advanced-filters .attributes .bedrooms ul li:last-of-type, +.property-search-bar.block .advanced-filters .attributes .bathrooms ul li:last-of-type { + border-right: 1px solid var(--platinum); } -.property-search-bar.block .select-item { - padding: 10px; - box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 15%); - width: 120px; - color: var(--body-color); - position: absolute; - background: var(--white); - z-index: 99; - white-space: nowrap; - left: -15px; - display: none; + .property-search-bar.block .advanced-filters .attributes .bedrooms ul li input[type="radio"], +.property-search-bar.block .advanced-filters .attributes .bathrooms ul li input[type="radio"] { + display: none; } -.property-search-bar.block .open .select-item { - display: block; +.property-search-bar.block .advanced-filters .attributes .bedrooms ul li label, +.property-search-bar.block .advanced-filters .attributes .bathrooms ul li label { + display: flex; + height: 50px; + margin: 0; + align-items: center; + justify-content: center; + font-size: var(--body-font-size-m); + font-weight: var(--font-weight-light); + color: var(--body-color); + text-transform: capitalize; } -.property-search-bar.block .open .multiple-inputs .select-item { - display: none; +.property-search-bar.block .advanced-filters .attributes .bedrooms ul li input[type="radio"]:checked+label, +.property-search-bar.block .advanced-filters .attributes .bathrooms ul li input[type="radio"]:checked+label { + background-color: var(--light-grey); } -.property-search-bar.block .multiple-inputs .select-item.show { - display: block; +.property-search-bar.block .advanced-filters .section-label { + display: block; + margin-bottom: 16px; + font-weight: var(--font-weight-bold); + font-size: var(--body-font-size-xs); + letter-spacing: var(--letter-spacing-xs); + text-transform: uppercase; } -.property-search-bar.block [name="Sort"].multiple-inputs .select-item, -.property-search-bar.block .search-results-dropdown .multiple-inputs .select-item{ - width: 125px; - left: 0; - border: 1px solid var(--platinum); - max-height: 185px; - overflow-y: scroll; +.property-search-bar.block .advanced-filters label { + display: block; + margin: 8px 0; + font-size: var(--body-font-size-s); + letter-spacing: var(--letter-spacing-xs); + text-transform: uppercase; } -.property-search-bar.block .filter .tile li { - flex: 1; - border: 1px solid #dee2e6; - border-left: none; - text-align: center; + +.property-search-bar.block .advanced-filters input[type="radio"] { + position: absolute; + width: 20px; + height: 20px; + opacity: 0; } -.property-search-bar.block .filter li:first-of-type { - border-left: 1px solid #dee2e6; +.property-search-bar.block .advanced-filters .radio-button { + position: relative; + width: 20px; + height: 20px; + border: 1px solid var(--grey); + border-radius: 100%; } -.property-search-bar.block .filter-checkbox label { - display: flex; - justify-content: flex-start; - align-items: center; +.property-search-bar.block .advanced-filters input[type="radio"]:checked+.radio-button { + background-color: var(--black); + border-color: var(--black); } -.property-search-bar.block .filter input[type="radio"] { - position: absolute; - width: 20px; - height: 20px; - min-width: 20px; - opacity: 0 +.property-search-bar.block .advanced-filters input[type="radio"]:checked+.radio-button::after { + position: absolute; + content: ''; + display: block; + width: 10px; + height: 10px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: var(--white); + border-radius: 100%; } -.property-search-bar.block .filter li label{ - height: 50px; - display: flex; - justify-content: center; - align-content: center; - flex-wrap: wrap; - font-weight: 300; - color: #495057; +.property-search-bar.block .advanced-filters .filter-toggle { + display: flex; + gap: 1em; + align-items: center; + font-size: var(--body-font-size-s); + text-transform: uppercase; } -.property-search-bar.block .filter li input[type="radio"]:checked+label { - background-color: var(--light-grey); - text-align: center; +.property-search-bar.block .advanced-filters .filter-toggle .checkbox { + min-width: 24px; + height: 16px; + border-radius: 100px; + position: relative; + border: 1px solid #b4b4b4; + background: var(--white); } -.property-search-bar.block .filter .multiple-inputs .select-item{ - border-bottom: 1px solid var(--platinum); - max-height: 185px; - overflow-y: scroll; - width: 100%; - left: 0; +.property-search-bar.block .advanced-filters .filter-toggle .checkbox::before { + content: ''; + position: absolute; + right: 2px; + top: 2px; + height: 10px; + width: 10px; + border-radius: 10px; + z-index: 1; + background: #b4b4b4; +} +.property-search-bar.block .advanced-filters .filter-toggle .checkbox.checked { + background: var(--body-color); + border: 1px solid transparent } -/* suggestions */ -.property-search-bar.block .search-bar .suggester-results { - display: none; - position: absolute; - left: 0; - width: 100%; - max-height: 300px; - overflow-y: scroll; - background-color: var(--white); - border: 1px solid var(--grey); - box-shadow: 0 3px 9px 2px rgba(0 0 0 / 23%); - z-index: 10; - line-height: 1.5; +.property-search-bar.block .advanced-filters .filter-toggle .checkbox.checked::before { + transform: translateX(-8px); + background: var(--white); } -.property-search-bar.block .search-bar.show-suggestions .suggester-results { - display: block; +.property-search-bar.block .advanced-filters .filter-toggle.disabled { + pointer-events: none; + opacity: .3; } -.property-search-bar.block .search-bar .suggester-results > li > ul { - padding: 0 15px; +.property-search-bar.block .advanced-filters .property-types div.options { + display: flex; + flex-wrap: wrap; + margin-bottom: 16px; } -.property-search-bar.block .search-bar .suggester-results > li > ul > li { - padding: 8px 0; - font-size: var(--body-font-size-m); - font-weight: 400; - text-transform: none; - letter-spacing: normal; +.property-search-bar.block .advanced-filters .property-types div.options button { + flex-basis: 50%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + height: 80px; + width: 100%; + border: 1px solid var(--platinum); + background-color: var(--white); } -.property-search-bar.block .search-bar .suggester-results .list-title { - padding: 15px 15px 5px; - font-family: var(--font-family-primary); - font-size: var(--body-font-size-s); - font-weight: 700; - text-transform: uppercase; - letter-spacing: .5px; - color: var(--black); +.property-search-bar.block .advanced-filters .property-types div.options button.selected { + background-color: var(--platinum); + border: 1px solid var(--grey); } -.property-search-bar.block [name="Sort"].multiple-inputs .select-item li, -.property-search-bar.block .filter .multiple-inputs .select-item li { - border: none; +.property-search-bar.block .advanced-filters .property-types div.options button > svg { + height: 21px; + width: 21px; } +.property-search-bar.block .advanced-filters .property-types div.options button > span { + text-align: center; + white-space: break-spaces; + font-size: var(--body-font-size-s); + line-height: var(--line-height-xs); +} -.property-search-bar.block .search-results-dropdown .select-item li:first-child { - border-top: 1px solid var(--platinum); +.property-search-bar.block .advanced-filters .property-types .all label { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; } -.property-search-bar.block .search-results-dropdown .select-item li:last-child { - border-bottom: 1px solid var(--platinum); +.property-search-bar.block .property-types .all input[type="checkbox"] { + position: absolute; + height: 24px; + width: 24px; + margin: 0; + opacity: 0; } +.property-search-bar.block .advanced-filters .property-types .all .checkbox { + position: relative; + height: 24px; + width: 24px; + border: 1px solid var(--grey); + z-index: 100; +} -.property-search-bar.block .search-results-dropdown .multiple-inputs .select-item li { - border: none; +.property-search-bar.block .advanced-filters .property-types .all .checkbox svg { + display: none; + position: absolute; + top: calc(50% - 5px); + left: 5px; + height: 10px; + width: 12px; + filter: brightness(0) saturate(100%) invert(12%) sepia(4%) saturate(1639%) hue-rotate(303deg) brightness(97%) contrast(94%); } -.property-search-bar.block .search-results-dropdown .select-item .custom-tooltip { - background: var(--white); - position: absolute; - left: 100%; - top: 0; +.property-search-bar.block .advanced-filters .property-types .all input[type="checkbox"]:checked+.checkbox svg { + display: block; } -.property-search-bar.block .search-results-dropdown .select-item li a { - display: flex; - align-items: center; +.property-search-bar.block .advanced-filters .property-types .all span.label { + font-size: var(--body-font-size-s); + letter-spacing: var(--letter-spacing-m); + text-transform: initial; + line-height: unset; } -.property-search-bar.block .multiple-inputs { - display: flex; - align-items: center; - color: var(--body-color); - background-color: var(--white); +.property-search-bar.block .advanced-filters .keywords { + background-color: var(--light-grey); } -.property-search-bar.block .input-dropdown input::placeholder { - color: var(--input-placeholder); - opacity: 1; /* Firefox */ +.property-search-bar.block .advanced-filters .keywords .keywords-input { + display: flex; + padding: 1px 7px; + height: 50px; + align-items: center; + gap: 7px; + background-color: var(--white); + border: 1px solid var(--grey); } -.property-search-bar.block [name="Price"] .search-results-dropdown, -.property-search-bar.block [name="LivingArea"] .search-results-dropdown -{ - position: absolute; - padding: 10px; - background-color: var(--white); - z-index: 1000; +.property-search-bar.block .advanced-filters .keywords .keywords-input input { + flex: 1 1 auto; + height: 35px; + border: none; + font-weight: var(--font-weight-light); + line-height: var(--line-height-m); + letter-spacing: normal; } -.property-search-bar.block .shadow { - box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 15%) !important; +.property-search-bar.block .advanced-filters .keywords .keywords-input input::placeholder { + font-weight: var(--font-weight-light); + line-height: var(--line-height-m); + letter-spacing: normal; } -.property-search-bar.block .multiple-inputs section div:first-child { - width: 125px; +.property-search-bar.block .advanced-filters .keywords .keywords-input button { + padding: 7px 25px; + background-color: var(--white); + border: 1px solid var(--grey); + vertical-align: middle; + text-align: center; } -.property-search-bar.block .multiple-inputs .select-selected { - color: var(--black); - text-align: left; - font-size: var(--body-font-size-s); - height: 45px; - font-family: var(--font-family-primary); - line-height: 45px; - border: 1px solid var(--platinum); - padding: 0 15px; - position: relative; - letter-spacing: var(--letter-spacing-xxs); +.property-search-bar.block .advanced-filters .keywords .keywords-input button span { + font-size: var(--body-font-size-s); + letter-spacing: var(--letter-spacing-m); + text-transform: uppercase; } -.property-search-bar.block .search-listing-block > .button-container { - order: 3; +.property-search-bar.block .advanced-filters .keywords .keywords-list { + margin: 8px 0; + display: flex; + flex-wrap: wrap; + gap: 8px; } -.property-search-bar.block .container-item, -.property-search-bar.block .search-listing-block .button-container:last-child { - display: none; +.property-search-bar.block .advanced-filters .keywords .keywords-list .keyword { + padding: 10px; + background-color: var(--white); } -.property-search-template.no-scroll { - height: 100%; - overflow: hidden; +.property-search-bar.block .advanced-filters .keywords .keywords-list .keyword span { + font-size: var(--body-font-size-m); + line-height: var(--line-height-xs); + color: var(--body-color); } -.property-search-bar.block .filter-block { - top: 115px; - letter-spacing: 0.5px; - position: fixed; - overflow-y: auto; - left: 0; - width: 100vw; - height: calc(100% - var(--nav-height) - 115px); - background-color: var(--white); - z-index: 999; +.property-search-bar.block .advanced-filters .keywords .keywords-list .keyword span.close { + color: var(--black); + margin-left: 4px; } -.property-search-bar.block .filter-block .multiple-inputs { - margin-right: 2rem; +.property-search-bar.block .advanced-filters .keywords .keywords-match { + display: flex; + gap: 14px; } -.property-search-bar.block [name="OpenHouses"] > div > div:not(.filter-checkbox) { - display: none; +.property-search-bar.block .advanced-filters .keywords .keywords-match label, +.property-search-bar.block .advanced-filters .open-houses .open-houses-timeframe label { + display: flex; + margin: 0; + gap: 5px; + align-items: center; } -.property-search-bar.block [name="Sort"].select-selected, -.property-search-bar.block [name="Sort"] .search-results-dropdown .select-item li { - font-size: var(--body-font-size-xs); - letter-spacing: var(--letter-spacing-reg); - color: var(--body-color); - cursor: pointer; +.property-search-bar.block .advanced-filters .keywords .keywords-match label span, +.property-search-bar.block .advanced-filters .open-houses .open-houses-timeframe label span { + font-size: var(--body-font-size-s); + text-transform: capitalize; } -.property-search-bar.block .search-bar .suggester-results > li:first-child:not(:only-child){ - display: none; +.property-search-bar.block .advanced-filters .misc hr { + margin: 16px 0; + border: none; + border-top: 1px solid var(--platinum); } -.property-search-bar.block [name="Sort"].select-selected { - border: 1px solid var(--grey); - height: 35px; - line-height: 35px; +.property-search-bar.block .advanced-filters .misc .filter-toggle { + justify-content: space-between; } -.property-search-bar.block [name="Sort"].multiple-inputs .select-item { - left: 0; - width: 100%; +.property-search-bar.block .advanced-filters .misc .filter-toggle label { + font-size: var(--body-font-size-s); + font-weight: var(--font-weight-bold); } -.property-search-bar.block [name="Sort"].multiple-inputs section div:first-child { - width: 145px; +.property-search-bar.block .advanced-filters .misc .open-houses .open-houses-timeframe { + display: none; } -.property-search-bar.block [name="Sort"] .search-results-dropdown .select-item li:first-child { - border-top: none; +.property-search-bar.block .advanced-filters .misc .open-houses .open-houses-timeframe.visible { + display: flex; + gap: 16px; } -.property-search-bar.block [name="Sort"] .search-results-dropdown .select-item li:last-child { - border-bottom: none; +.property-search-bar.block .advanced-filters .buttons { + position: sticky; + bottom: 0; + display: flex; + flex-wrap: wrap; + justify-content: center; + z-index: 502; + background-color: var(--white); + box-shadow: 0 0 4px 0 rgb(0 0 0 / 50%); } -.property-search-bar.block .filter-block .multiple-inputs section div:first-child { - width: 100%; - position: relative; +.property-search-bar.block .advanced-filters .buttons p.button-container { + margin-bottom: 0; +} +.property-search-bar.block .advanced-filters .buttons p.button-container a { + padding: 7px 25px; } -.property-search-bar.block .filter-block button { - font-family: var(--font-family-primary); +.property-search-bar.block .advanced-filters .buttons p.button-container a, +.property-search-bar.block .advanced-filters .buttons p.button-container.secondary a:hover { + background-color: var(--primary-color); + border-color: var(--white); + color: var(--white); } -.property-search-bar.block .filter-block [name="Features"] { - background: var(--light-grey); - padding: 27px 20px; +.property-search-bar.block .advanced-filters .buttons p.button-container a:hover, +.property-search-bar.block .advanced-filters .buttons p.button-container.secondary a { + background-color: var(--white); + color: var(--primary-color); + border-color: var(--primary-color); } -.property-search-bar.block [name="Features"] input[type="text"]{ - height: 35px; - border: none; - flex: 0 0 83.3333%; - padding: 1px 15px 1px 7px; +.property-search-bar.block .search-overlay { + display: none; + position: absolute; + width: 100vw; + height: calc(100vh - var(--nav-height) - 50px); + top: 50px; + left: 0; + background-color: var(--dark-grey); + opacity: .5; + z-index: 2; } -.property-search-bar.block .container-input { - height: 50px; - border: 1px solid var(--grey); - padding-right: 7px; - background: var(--white); +.property-search-bar.block .search-overlay.visible { + display: block; } -.property-search-bar.block .container-input .button.secondary { - flex: 0 0 16.6667%; - border: 1px solid var(--grey); + +/* !* suggestions *! */ +.property-search-bar.block .search-bar .suggester-results { + display: none; + position: absolute; + left: 0; + width: 100%; + max-height: 300px; + overflow-y: scroll; + background-color: var(--white); + border: 1px solid var(--grey); + box-shadow: 0 3px 9px 2px rgba(0 0 0 / 23%); + z-index: 510; + line-height: 1.5; } -.property-search-bar.block .filter li input[type="radio"] { - display: none; +.property-search-bar.block .search-bar.show-suggestions .suggester-results { + display: block; } -.property-search-bar.block .filter .radio-btn { - width: 20px; - height: 20px; - border: 1.4px solid #cecece; - margin-right: 8px; - position: relative; - border-radius: 100%; - overflow: hidden; +.property-search-bar.block .search-bar .suggester-results .list-title { + padding: 15px 15px 5px; + font-family: var(--font-family-primary); + font-size: var(--body-font-size-s); + font-weight: var(--font-weight-bold); + text-transform: uppercase; + letter-spacing: .5px; + color: var(--black); } -.property-search-bar.block .filter input[type="radio"]:checked+.radio-btn { - background: black; - border-color: black; +.property-search-bar.block .search-bar .suggester-results > li > ul { + padding: 0 15px; } -.property-search-bar.block .filter input[type="radio"]:checked+.radio-btn::after { - content: ""; - display: block; - width: 10px; - height: 10px; - background: white; - border-radius: 100%; - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); +.property-search-bar.block .search-bar .suggester-results > li > ul > li { + padding: 8px 0; + font-size: var(--body-font-size-m); + font-weight: 400; + text-transform: none; + letter-spacing: normal; } -.property-search-bar.block .column-2 .column { - flex: 0 0 50%; - width: 100%; +.property-search-bar.block .search-bar .suggester-results > li:first-child:not(:only-child) { + display: none; } -.property-search-bar.block .column-2 .column button { - border: 1px solid #e7e7e7; - flex-direction: column; - height: 80px; +@media screen and (min-width: 600px) { + .property-search-bar.block .advanced-filters .property-types div.options { + gap: 8px; + } + + .property-search-bar.block .advanced-filters .property-types div.options button { + flex-basis: calc(50% - 4px); + display: flex; + flex-direction: row; align-items: center; + justify-content: flex-start; + gap: 16px; + height: 40px; width: 100%; - justify-content: center; - overflow: visible; - background: var(--white); + } } -.property-search-bar.block .filter .column svg { - height: 21px; - width: 20px; - pointer-events: none; -} +@media screen and (min-width: 900px) { + .property-search-bar.block .search-bar { + flex: 1 1 auto; + } -.property-search-bar.block .column-2 .column button.selected { - background: var(--platinum); - border: 1px solid grey; -} + .property-search-bar.block form .result-filters { + order: 3; + } + + .property-search-bar.block .result-filters .range-wrapper, + .property-search-bar.block .result-filters .select-wrapper { + display: flex; + } + + .property-search-bar.block form a.search-submit { + order: 2; + } -.property-search-bar.block .section-label { - font-family: var(--font-family-primary); - font-weight: 700; + .property-search-bar.block form a.save-search { + display: initial; + padding: 7px 10px; + border: 1px solid var(--white); font-size: var(--body-font-size-s); - color: var(--body-color); - margin: 0 0 16px; - line-height: 1; - display: block; -} + font-weight: var(--font-weight-bold); + letter-spacing: var(--letter-spacing-m); + line-height: var(--line-height-s); + white-space: nowrap; + } -.property-search-bar.block .filter-checkbox input[type="checkbox"] { - height: 24px; - min-width: 24px; - width: 24px; - border-radius: 50%; - border: 1px solid var(--body-color); - opacity: 0; - position: absolute; - z-index: 1; -} + .property-search-bar.block form a.save-search span { + font-size: inherit; + font-weight: inherit; + letter-spacing: inherit; + line-height: inherit; + color: var(--white); + text-transform: uppercase; + } -.property-search-bar.block .filter-checkbox .checkbox svg { + .property-search-bar.block .advanced-filters { + width: 600px; + right: 0; + left: unset; + } + + .property-search-bar.block .advanced-filters .attributes { display: none; - height: 10px; - width: 12px; - top: calc(50% - 5px); - position: absolute; - left: 5px; -} + } -.property-search-bar.block .filter-checkbox input[type="checkbox"]:checked+.checkbox svg { - display: block; + .property-search-bar.block .search-overlay { + position: fixed; + height: unset; + top: calc(var(--nav-height) + 50px); + left: 0; + right: 0; + bottom: 0; + background-color: var(--dark-grey); + opacity: .5; + } } - -.property-search-bar.block [name="FeaturedCompany"], -.property-search-bar.block [name="Luxury"], [name="RecentPriceChange"], [name="NewListing"] { - display: flex; - justify-content: space-between; -} - -.property-search-bar.block .tag { - background-color: var(--white); - padding: 10px; - margin-top: 10px; - margin-right: 10px; - font-size: var(--body-font-size-m); - letter-spacing: initial; - line-height: 33px; -} - -.tag .close::after { - content: "x"; -} - -.property-search-bar.block .filter-buttons.button-container.flex-row.vertical-center { - margin: 0; -} - -@media (min-width: 600px) { - .property-search-bar.block .column-2 .column button { - flex-direction: row; - height: 40px; - align-items: center; - width: 100%; - justify-content: start; - overflow: visible; - margin-bottom: 0.5rem; - } - - .property-search-bar.block .filter .column svg { - height: 21px; - width: 20px; - pointer-events: none; - } - - .property-search-bar.block .column-2 .column:last-child { - padding-left: 0.5rem; - } -} - -.property-search-bar.block [name="OpenHouses"].selected > div > div:not(.filter-checkbox) { - display: block; -} - -@media (min-width: 900px) { - .property-search-template .section.property-search-bar-container { - margin-bottom: 0; - position: sticky; - top: 140px; - z-index: 10; - background: white; - } - - .property-search-bar.block .overlay { - top: calc(var(--nav-height) + 50px); - } - - .property-search-bar.block .search-listing-container { - justify-content: center; - } - - .property-search-bar.block .search-listing-block { - max-width: var(--normal-page-width); - justify-content: space-between; - } - - .property-search-bar.block .container-item, - .property-search-bar.block .search-listing-block .button-container:last-child { - display: block; - } - - .property-search-bar.block .filter-block { - width: 600px; - height: calc(100% - 110px - var(--nav-height)); - top: calc(var(--nav-height) + 50px); - left: calc(100% - 600px); - } - - .property-search-bar.block .filter-buttons { - justify-content: flex-start; - padding: 15px 15px 15px 20px; - width: 600px; - left: calc(100% - 600px); - box-shadow: none; - border-top: 1px solid var(--body-color); - } - - .property-search-bar.block .filter-block [name="Price"], - .property-search-bar.block .filter-block [name="LivingArea"], - .property-search-bar.block .filter-block [name="MinBedroomsTotal"], - .property-search-bar.block .filter-block [name="MinBathroomsTotal"], - .property-search-bar.block .filter-block [name="ApplicationType"] { - display: none; - } - - .property-search-bar.block .search-listing-block > div { - order: 3; - } - - .property-search-bar.block .search-listing-container > div:first-of-type { - display: flex; - width: 100%; - justify-content: space-evenly; - background: var(--primary-color); - } - - .property-search-bar.block .search-listing-container > div:last-of-type { - display: flex; - flex-basis: 100%; - max-width: var(--normal-page-width); - } - - .property-search-bar.block .result-filters [name="ApplicationType"], - .property-search-bar.block .result-filters .map-toggle { - display: flex - } - - .property-search-bar.block .bf-container { - justify-content: space-between; - } - - .property-search-bar.block .primary-search { - max-width: 40vw; - } - - .property-search-bar.block .search-listing-block .multiple-inputs input { - border: 1px solid #dee2e6; - margin: 0; - color: var(--black); - width: 165px; - } -} \ No newline at end of file diff --git a/blocks/property-search-bar/property-search-bar.js b/blocks/property-search-bar/property-search-bar.js index 74406e02..5a432e3b 100644 --- a/blocks/property-search-bar/property-search-bar.js +++ b/blocks/property-search-bar/property-search-bar.js @@ -1,40 +1,71 @@ import { - populatePreSelectedFilters, setInitialValuesFromUrl, searchProperty, -} from './filter-processor.js'; + BED_BATHS, + buildDataListRange, + buildFilterSelect, + buildSelectRange, + getPlaceholder, +} from '../shared/search/util.js'; +import { decorateIcons, loadScript } from '../../scripts/aem.js'; -import { - buildFilterSearchTypesElement, closeTopLevelFilters, -} from './common-function.js'; -import topMenu from './filters/top-menu.js'; -import additionalFilters from './filters/additional-filters.js'; -import layoutButtons from './filters/additional-filter-buttons.js'; +export const SQUARE_FEET = [ + { value: '500', label: '500 Sq Ft' }, + { value: '750', label: '750 Sq Ft' }, + { value: '1000', label: '1,000 Sq Ft' }, + { value: '1250', label: '1,250 Sq Ft' }, + { value: '1500', label: '1,500 Sq Ft' }, + { value: '1750', label: '1,750 Sq Ft' }, + { value: '2000', label: '2,000 Sq Ft' }, + { value: '2250', label: '2,250 Sq Ft' }, + { value: '2500', label: '2,500 Sq Ft' }, + { value: '2750', label: '2,750 Sq Ft' }, + { value: '3000', label: '3,000 Sq Ft' }, + { value: '3500', label: '3,500 Sq Ft' }, + { value: '4000', label: '4,000 Sq Ft' }, + { value: '5000', label: '5,000 Sq Ft' }, + { value: '7500', label: '7,500 Sq Ft' }, +]; -// const event = new Event('onFilterChange'); +function buildBar() { + const div = document.createElement('div'); + div.classList.add('search-form-wrapper'); + div.innerHTML = ` +
    + +
    + ${buildDataListRange('price', 'Price').outerHTML} + ${buildFilterSelect('bedrooms', 'Beds', BED_BATHS).outerHTML} + ${buildFilterSelect('bathrooms', 'Baths', BED_BATHS).outerHTML} + ${buildSelectRange('sqft', 'Square Feet', SQUARE_FEET).outerHTML} + + + + + Save Search +
    + + + +
    +
    +
    +
    + `; + return div; +} export default async function decorate(block) { - setInitialValuesFromUrl(); - /** build top menu html */ - const overlay = document.createElement('div'); - overlay.classList.add('overlay', 'hide'); - const additionalConfig = document.createElement('div'); - additionalConfig.append(buildFilterSearchTypesElement()); - const topMenuBlock = await topMenu.build(); - const additionalFiltersBlock = await additionalFilters.build(); - const buttons = await layoutButtons.build(); - block.append(topMenuBlock, additionalFiltersBlock, overlay, buttons); - - populatePreSelectedFilters(); - - // close filters on click outside - document.addEventListener('click', (e) => { - if (!block.contains(e.target)) { - closeTopLevelFilters(); - } - }); - - window.addEventListener('onFilterChange', (e) => { - e.preventDefault(); - e.stopPropagation(); - searchProperty(); - }); + block.replaceChildren(buildBar()); + decorateIcons(block); + window.setTimeout(() => { + loadScript(`${window.hlx.codeBasePath}/blocks/property-search-bar/delayed.js`, { type: 'module' }); + }, 3000); } diff --git a/blocks/property-search-bar/search-results-dropdown.css b/blocks/property-search-bar/search-results-dropdown.css deleted file mode 100644 index 36ad67c5..00000000 --- a/blocks/property-search-bar/search-results-dropdown.css +++ /dev/null @@ -1,66 +0,0 @@ -.property-search-template .highlighted { - background: var(--light-grey); -} - -.property-search-template .select-item::-webkit-scrollbar { - width: 14px; -} - -.property-search-template .select-item::-webkit-scrollbar-thumb{ - background: #c1c1c1; - border-radius: 14px; - border: 3px solid var(--platinum) -} - -.property-search-template .select-item::-webkit-scrollbar-track{ - border-radius: 0; - background: var(--platinum); -} - -.property-search-template .multiple-inputs .select-selected::after { - position: absolute; - content: ""; - height: 7px; - width: 12px; - background-size: cover; - background-position: 50% 50%; - background-repeat: no-repeat; - top: calc(50% - 3.5px); - right: 15px; - background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHdpZHRoPSIxMnB4IiBoZWlnaHQ9IjdweCIgdmlld0JveD0iMCAwIDEyIDciIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+ICAgICAgICA8dGl0bGU+VUkvQ2Fycm90L0NvbGxhcHNlL0RhcmtHcmF5IENvcHkgMzwvdGl0bGU+ICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPiAgICA8ZGVmcz48L2RlZnM+ICAgIDxnIGlkPSItLS0t4pe877iOLVVJLUVsZW1lbnRzIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMSIgZmlsbD0ibm9uZSIgZmlsbC1ydWxlPSJldmVub2RkIj4gICAgICAgIDxnIGlkPSJVSS1FbGVtZW50cy9JY29ucyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTM5NC4wMDAwMDAsIC0yODguMDAwMDAwKSIgZmlsbD0iI0FBQUFBQSI+ICAgICAgICAgICAgPGcgaWQ9IlVJL0NhcnJvdC9FeHBhbmQvR3JheSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzk0LjAwMDAwMCwgMjg4LjAwMDAwMCkiPiAgICAgICAgICAgICAgICA8cG9seWdvbiBpZD0iUGF0aC0yIiBwb2ludHM9IjAuNzM0NTU4NTc2IDAuNjcxMzk5NzczIDExLjI5MzM0NTkgMC42NzEzOTk3NzMgNi4wMTM5NTIyNSA2LjY2MDUzMjIiPjwvcG9seWdvbj4gICAgICAgICAgICA8L2c+ICAgICAgICA8L2c+ICAgIDwvZz48L3N2Zz4="); -} - -.property-search-template .select-item { - padding: 10px; - box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 15%); - width: 120px; - color: var(--body-color); - position: absolute; - background: var(--white); - z-index: 99; - white-space: nowrap; - left: -15px; - display: none; -} - -.property-search-template .search-results-dropdown div:first-child{ - position: relative; -} - -.property-search-template .select-item li, -.property-search-template .search-results-dropdown .select-item li{ - display: flex; - border-left: 1px solid var(--platinum); - border-right: 1px solid var(--platinum); - padding: 4px 10px; - line-height: 30px; - letter-spacing: var(--letter-spacing-xxs); - font-size: var(--body-font-size-s); - align-items: center; - justify-content: flex-start; - position: relative -} - -.property-search-template .select-item li:hover { - background: var(--light-grey); -} \ No newline at end of file diff --git a/blocks/property-search-bar/search/suggestion.js b/blocks/property-search-bar/search/suggestion.js deleted file mode 100644 index 36ca8cd1..00000000 --- a/blocks/property-search-bar/search/suggestion.js +++ /dev/null @@ -1,50 +0,0 @@ -import { setFilterValue } from '../filter-processor.js'; - -const CONFIG = { - Address: 'CoverageZipcode', - PostalCode: 'CoverageZipcode', - Neighborhood: 'BID', - School: 'BID', - 'School District': 'BID', - MLSListingKey: 'ListingId', -}; - -function parseQueryString(queryString) { - return queryString.split('&').reduce((resultObject, pair) => { - const [key, value] = pair.split('='); - resultObject[key] = decodeURIComponent(value.replace(/\+/g, '%20')); - return resultObject; - }, {}); -} - -export function getAttributes(result) { - const query = parseQueryString(result.QueryString); - query.BID = result.BID ?? ''; - const type = query.SearchType; - const key = CONFIG[type]; - const output = { - 'search-input': result.displayText, - 'search-type': type, - 'search-parameter': query.SearchParameter, - }; - if (Object.keys(CONFIG).includes(type)) { - output[key] = query[key]; - } - return output; -} - -export function setSearchParams(target) { - const searchParameter = target.getAttribute('search-parameter'); - const keyword = target.getAttribute('search-input'); - const type = target.getAttribute('search-type'); - let attr; - setFilterValue('SearchType', type); - setFilterValue('SearchInput', keyword); - setFilterValue('SearchParameterOriginal', searchParameter); - Object.keys(CONFIG).forEach((key) => { - attr = CONFIG[key].toLowerCase(); - if (target.hasAttribute(attr)) { - setFilterValue(CONFIG[key], target.getAttribute(attr)); - } - }); -} diff --git a/blocks/property-search-results/README.md b/blocks/property-search-results/README.md new file mode 100644 index 00000000..a8bf35a1 --- /dev/null +++ b/blocks/property-search-results/README.md @@ -0,0 +1,50 @@ +### Property Search Results + +This block renders the map and list of properties returned from a search. + +#### Default Search + +If no parameters were used (i.e. someone typed in the URL of the page), a default Search parameter set is used. + +#### Keeping in Sync + +This and the Property Search Bar block have shared parameters. When this block is used to set parameters, the search is performed immediately and the Search Bar state is updated (see `updateForm` in the Property Search Bar `delayed.js`) + +If the Property Search Bar is updated and a search submitted or applied, it emits an event, which is processed by this block. The `updateFilters` block will keep the few items in sync when the new search is executed. + +#### Results, Map and Browser History + +This page rendering these results should not refresh each time a new search is performed. So we're using `pushState` and `popstate` history manipulation. + +When new search parameters are provided, push that state to the history, then perform the search (via `doSearch`); + +The trick here is making sure that cyclic searching doesn't occur. + +When the page is first rendered: + 1. Generate and center the map + 1. Perform the search. + 1. Render results + 1. Decorate the map. + +Any time a Search Event occurs: + 1. Perform the search. + 1. Render results + 1. Decorate the map. + +If the Map bounds change: + 1. Create a new Search object with the bounds + 1. Emit the Search Event with the payload + +If the Search Filters change: + 1. Create a new Search object with the bounds + 1. Emit the Search Event with the payload + +If a history pop-state occurs: + 1. Sync the Filters to the history state + 1. Tell Search Bar to sync its state. + 1. Create search from history URL, then perform the search. + 1. Render results + 1. Decorate the map. + + +Anytime the Map is decorating, which causes the bounds to change, flag that it's rendering. This prevents a render event from causing a second, new search. diff --git a/blocks/property-search-results/loader.js b/blocks/property-search-results/loader.js new file mode 100644 index 00000000..152bbf10 --- /dev/null +++ b/blocks/property-search-results/loader.js @@ -0,0 +1,10 @@ +import { div, img, domEl } from '../../scripts/dom-helpers.js'; + +const loader = div({ class: 'search-results-loader' }, + div({ class: 'animation' }, + domEl('picture', img({ src: '/styles/images/loading.png' })), + div({ class: 'pulse' }), + ), +); + +export default loader; diff --git a/blocks/property-search-results/map.css b/blocks/property-search-results/map.css new file mode 100644 index 00000000..f4eabc63 --- /dev/null +++ b/blocks/property-search-results/map.css @@ -0,0 +1,562 @@ +.property-search-results.block .search-map-container { + position: relative; + width: 100%; + height: 100%; + background-color: var(--white); + min-height: 250px; +} + +.property-search-results.block .search-map-container.drawing { + cursor: pointer; +} + +.property-search-results.block .search-map-container .search-results-map { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.property-search-results.block .search-map-container .search-results-map #gmap-canvas { + height: 100%; + width: 100%; +} + +.property-search-results.block .search-map-container .map-controls-wrapper { + position: absolute; + display: flex; + flex-direction: column; + right: 15px; + bottom: 15px; + pointer-events: none; + z-index: 1; +} + +.property-search-results.block .search-map-container .map-controls-wrapper .custom-controls { + display: flex; + flex-direction: column; + gap: 15px; +} + +.property-search-results.block .search-map-container .custom-controls > a { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-evenly; + height: 45px; + width: 45px; + box-shadow: 0 0 3px 2px rgba(0 0 0 / 30%); + pointer-events: all; + background-color: var(--white); + cursor: pointer; +} + +.property-search-results.block .search-map-container .custom-controls a span { + font-family: var(--font-family-proxima); + font-size: var(--body-font-size-xxs); + line-height: 1em; + letter-spacing: normal; + color: var(--body-color); +} + +.property-search-results.block .search-map-container.drawing .custom-controls .map-style { + pointer-events: none; +} + +.property-search-results.block .search-map-container.satellite .custom-controls .map-style span.map { + display: none; +} + +.property-search-results.block .search-map-container.map .custom-controls .map-style span.satellite { + display: none; +} + +.property-search-results.block .search-map-container .custom-controls .map-style img { + height: 24px; + width: 24px; +} + +.property-search-results.block .search-map-container.drawing .custom-controls .map-style * { + opacity: .3; +} + +.property-search-results.block .search-map-container .custom-controls .map-draw img.draw { + height: 17px; + width: 17px; +} + +.property-search-results.block .search-map-container .custom-controls .map-draw img.close { + height: 22px; + width: 22px; +} + +.property-search-results.block .search-map-container .custom-controls .map-draw img.close, +.property-search-results.block .search-map-container .custom-controls .map-draw span.close { + display: none; +} + +.property-search-results.block .search-map-container.drawing .custom-controls .map-draw img.draw, +.property-search-results.block .search-map-container.drawing .custom-controls .map-draw span.draw { + display: none; +} + +.property-search-results.block .search-map-container.drawing .custom-controls .map-draw img.close, +.property-search-results.block .search-map-container.drawing .custom-controls .map-draw span.close { + display: initial; +} + +.property-search-results.block .search-map-container .custom-controls .map-draw-complete { + display: none; +} + +.property-search-results.block .search-map-container .custom-controls .map-draw-complete.disabled { + pointer-events: none; +} + +.property-search-results.block .search-map-container .custom-controls .map-draw-complete.disabled * { + opacity: .3; +} + +.property-search-results.block .search-map-container.drawing .custom-controls .map-draw-complete { + display: flex; +} + +.property-search-results.block .search-map-container.drawing .custom-controls .map-draw-complete img { + height: 33px; + width: 33px; +} + +.property-search-results.block .search-map-container .custom-controls .zoom-controls { + display: none; +} + +.property-search-results.block .search-map-container.drawing .custom-controls .zoom-controls a { + pointer-events: none; +} + +.property-search-results.block .search-map-container.drawing .custom-controls .zoom-controls a::before, +.property-search-results.block .search-map-container.drawing .custom-controls .zoom-controls a::after { + opacity: .3; +} + + +.property-search-results.block .search-map-container .map-draw-info { + display: none; +} + +.property-search-results.block .search-map-container.drawing .map-draw-info { + display: block; + position: absolute; + top: 0; + width: 100%; + background-color: var(--light-grey); +} + +.property-search-results.block .search-map-container.drawing .map-draw-info p { + padding: 0; + margin: 0; + font-family: var(--font-family-proxima); + font-size: var(--body-font-size-xs); + line-height: var(--line-height-l); + text-align: center; + letter-spacing: normal; +} + +.property-search-results.block .search-map-container .map-draw-info .map-draw-boundary-link { + display: none; +} + +.property-search-results.block .search-map-container.bound .map-draw-info .map-draw-boundary-link { + display: initial; +} + +.property-search-results.block .search-map-wrapper .info-window { + position: relative; + background-color: var(--white); + z-index: 1; + border-bottom: 1px solid var(--platinum); +} + +.property-search-results.block .search-map-wrapper .info-window.cluster { + max-height: calc(var(--nav-height) + 50px); /* Search bar */ + overflow-y: scroll; +} + +.property-search-results.block .search-map-wrapper .info-window .loading { + padding: 10px; + margin: 20px; +} + +.property-search-results.block .search-map-wrapper .info-window .loading p { + font-size: var(--body-font-size-xl); + font-weight: var(--font-weight-semibold); + text-align: center; + margin: 0; +} + +.property-search-results.block .search-map-wrapper .info-window .loading p::after { + content: ''; + display: inline-block; + position: absolute; + right: 30px; + top: 0; + height: 100%; + width: 25px; + vertical-align: bottom; + animation: ellipsis 3s infinite; + background-color: white; +} + +@keyframes ellipsis { + to { + width: 0; + } +} + +.property-search-results.block .search-map-wrapper .info-window a.info-wrapper, +.property-search-results.block .search-map-wrapper .info-window.cluster a.info-wrapper { + display: grid; + margin: 10px; + align-items: center; + padding-bottom: 10px; + gap: 10px; + border-bottom: 1px solid var(--grey); + text-decoration: none; + grid-template: + "image info" + / 50px 1fr; +} + +.property-search-results.block .search-map-wrapper .info-window a.info-wrapper:last-of-type { + border-bottom: none; + padding-bottom: 0; +} + +.property-search-results.block .search-map-wrapper .info-window .info-wrapper .image-wrapper { + grid-area: image; + position: relative; + height: 105px; + width: 150px; +} + +.property-search-results.block .search-map-wrapper .mobile-info-window .info-wrapper .image-wrapper, +.property-search-results.block .search-map-wrapper .info-window.cluster .info-wrapper .image-wrapper { + position: relative; + height: 35px; + width: 50px; +} + +.property-search-results.block .search-map-wrapper .mobile-info-window .info-wrapper .image-wrapper .luxury, +.property-search-results.block .search-map-wrapper .info-window.cluster .info-wrapper .image-wrapper .luxury { + display: none; +} + +.property-search-results.block .search-map-wrapper .info-window .info-wrapper .image-wrapper img { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + object-fit: contain; + object-position: center; +} + +.property-search-results.block .search-map-wrapper .info-window .info-wrapper .info { + grid-area: info; + display: flex; + flex-direction: column; +} + +.property-search-results.block .search-map-wrapper .mobile-info-window .info-wrapper .info hr, +.property-search-results.block .search-map-wrapper .info-window.cluster .info-wrapper .info hr { + display: none; + +} + +.property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .price span { + display: block; + font-size: var(--body-font-size-l); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-s); + letter-spacing: var(--letter-spacing-xxs); +} + +.property-search-results.block .search-map-wrapper .mobile-info-window .info-wrapper .info .price .alt, +.property-search-results.block .search-map-wrapper .info-window.cluster .info-wrapper .info .price .alt { + display: none; +} + +.property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .details span { + display: block; + font-size: var(--body-font-size-xs); + letter-spacing: normal; + line-height: var(--line-height-xs); +} + +.property-search-results.block .search-map-wrapper .mobile-info-window .info-wrapper .info .details .address .danger, +.property-search-results.block .search-map-wrapper .info-window.cluster .info-wrapper .info .details .address .danger, +.property-search-results.block .search-map-wrapper .mobile-info-window .info-wrapper .info .details .address .locality, +.property-search-results.block .search-map-wrapper .info-window.cluster .info-wrapper .info .details .address .locality { + display: none; +} + +.property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .providers { + padding: 3px 0; + font-size: var(--body-font-size-xxs); + line-height: var(--line-height-m); + letter-spacing: normal; +} + +.property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .property-buttons, +.property-search-results.block .search-map-wrapper .info-window.cluster .info-wrapper .info .property-buttons { + display: none; +} + +.property-search-results.block .search-map-wrapper .mobile-info-window .info-wrapper .info .listing-info, +.property-search-results.block .search-map-wrapper .info-window.cluster .info-wrapper .info .listing-info { + display: none; +} + +#gmap-canvas a[href^="https://maps.google.com/maps"] { + display: none !important; /* Don't want to use this, but theres an inline style we have to override. */ +} + +#gmap-canvas .gm-style-cc { + display: none; +} + +/* overriding some google styles */ +.gm-style .gm-style-iw-d { + overflow: initial !important; +} + +.gm-style .gm-style-iw-d::-webkit-scrollbar-track, +.gm-style .gm-style-iw-d::-webkit-scrollbar-track-piece, +.gm-style .gm-style-iw-t::after, +.gm-style .gm-style-iw-c { + box-shadow: none !important; + border-radius: 0 !important; + padding: 0 !important; + overflow: initial !important; +} + +.cmp-properties-map .gm-ui-hover-effect { + display: none !important; +} + +.button.gm-ui-hover-effect, button[draggable] { + opacity: 0 !important; +} + +@media screen and (min-width: 900px) { + .property-search-results.block .search-map-container .map-controls-wrapper { + bottom: 50%; + transform: translateY(50%); + } + + .property-search-results.block .search-map-wrapper .mobile-info-window { + display: none; + } + + .property-search-results.block .search-map-wrapper .info-window { + max-width: 250px; + max-height: 250px; + overflow-y: scroll; + } + + .property-search-results.block .search-map-wrapper .info-window:not(.cluster) { + max-width: 200px; + padding: 0; + } + + .property-search-results.block .search-map-wrapper .info-window a.info-wrapper { + grid-template: + "image" + "info"; + margin: 0; + max-height: 250px; + overflow-y: scroll; + gap: 0; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .image-wrapper { + grid-area: image; + position: relative; + width: 100%; + padding-top: 73.5%; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .image-wrapper img { + object-fit: cover; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .image-wrapper .luxury { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + height: 30px; + text-transform: uppercase; + text-align: center; + background-color: var(--black); + z-index: 1; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .image-wrapper .luxury span { + font-size: var(--body-font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: var(--letter-spacing-m); + color: var(--white); + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info { + padding: 5px; + flex-flow: row wrap; + justify-content: space-between; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .price .alt { + display: block; + font-size: var(--body-font-size-s); + font-weight: var(--font-weight-normal); + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .property-buttons { + display: flex; + gap: 8px; + align-items: center; + margin: 0 5px; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .property-buttons a { + position: relative; + width: 18px; + height: 18px; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .property-buttons span { + position: absolute; + top: 0; + left: 0; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .property-buttons img { + width: 18px; + height: 18px; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .property-buttons a span.icon-envelopedark, + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .property-buttons a span.icon-heartemptydark, + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .property-buttons a:hover span.icon-envelope, + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .property-buttons a:hover span.icon-heartempty { + display: none; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .property-buttons a:hover span.icon-envelopedark, + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .property-buttons a:hover span.icon-heartemptydark { + display: block; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .property-buttons a span.icon-heartfull { + display: none; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .details > * { + padding: 3px; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .details .address .danger { + display: block; + color: #832b39; + font-size: var(--body-font-size-xxs); + font-weight: var(--font-weight-bold); + line-height: var(--line-height-m); + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info hr { + width: 100%; + border: none; + border-top: 1px solid var(--platinum); + margin: 0; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .listing-info { + display: block; + padding: 3px; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .listing-info span { + display: block; + font-size: 8px; + line-height: var(--line-height-s); + letter-spacing: normal; + color: var(--dark-grey); + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .listing-info .aor-img { + height: 30px; + width: 60px; + position: relative; + } + + .property-search-results.block .search-map-wrapper .info-window .info-wrapper .info .listing-info .aor-img img { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + object-fit: contain; + object-position: center; + } + + .property-search-results.block .search-map-container.drawing .map-draw-info p { + font-size: var(--body-font-size-m); + line-height: 65px; + } + + + .property-search-results.block .search-map-container .custom-controls .zoom-controls { + display: block; + box-shadow: 0 0 3px 2px rgba(0 0 0 / 30%); + } + + /* stylelint-disable-next-line no-descending-specificity */ + .property-search-results.block .search-map-container .custom-controls .zoom-controls a { + display: block; + position: relative; + height: 45px; + width: 45px; + background-color: var(--white); + cursor: pointer; + pointer-events: all; + + } + + .property-search-results.block .search-map-container .custom-controls .zoom-controls a::before { + content: ' '; + position: absolute; + display: block; + background-color: var(--body-color); + height: 1px; + left: calc(50% - 8px); + top: 50%; + width: 16px; + } + + + .property-search-results.block .search-map-container .custom-controls .zoom-controls a.zoom-in::after { + content: ' '; + position: absolute; + display: block; + background-color: var(--body-color); + width: 1px; + top: calc(50% - 8px); + left: 50%; + height: 16px; + } + +} diff --git a/blocks/property-search-results/map.js b/blocks/property-search-results/map.js new file mode 100644 index 00000000..4fe764ea --- /dev/null +++ b/blocks/property-search-results/map.js @@ -0,0 +1,434 @@ +/* global google */ + +import { loadScript } from '../../scripts/aem.js'; +import loadMaps from '../../scripts/google-maps/index.js'; +import { + a, div, img, p, span, +} from '../../scripts/dom-helpers.js'; +import displayClusters from './map/clusters.js'; +import { UPDATE_SEARCH_EVENT } from '../../scripts/apis/creg/search/Search.js'; +import BoxSearch from '../../scripts/apis/creg/search/types/BoxSearch.js'; +import { + clearInfos, + hideInfos, + getMarkerClusterer, + displayPins, +} from './map/pins.js'; +import { + startPolygon, + addPolygonPoint, + closePolygon, + clearPolygon, +} from './map/drawing.js'; +import PolygonSearch from '../../scripts/apis/creg/search/types/PolygonSearch.js'; + +const zoom = 10; +const maxZoom = 18; + +let gmap; +let renderInProgress = false; + +const mapMarkers = []; +let clusterer; +let boundsTimeout; + +let drawingClickListener; +let polygon; + +const MAP_STYLE = [{ + featureType: 'administrative', + elementType: 'labels.text.fill', + stylers: [{ color: '#444444' }], +}, { + featureType: 'administrative.locality', + elementType: 'labels.text.fill', + stylers: [{ saturation: '-42' }, { lightness: '-53' }, { gamma: '2.98' }], +}, { + featureType: 'administrative.neighborhood', + elementType: 'labels.text.fill', + stylers: [{ saturation: '1' }, { lightness: '31' }, { weight: '1' }], +}, { + featureType: 'administrative.neighborhood', + elementType: 'labels.text.stroke', + stylers: [{ visibility: 'off' }], +}, { + featureType: 'administrative.land_parcel', + elementType: 'labels.text.fill', + stylers: [{ lightness: '12' }], +}, { + featureType: 'landscape', + elementType: 'all', + stylers: [{ saturation: '67' }], +}, { + featureType: 'landscape.man_made', + elementType: 'geometry.fill', + stylers: [{ visibility: 'on' }, { color: '#ececec' }], +}, { + featureType: 'landscape.natural', + elementType: 'geometry.fill', + stylers: [{ visibility: 'on' }], +}, { + featureType: 'landscape.natural.landcover', + elementType: 'geometry.fill', + stylers: [{ visibility: 'on' }, { color: '#ffffff' }, { saturation: '-2' }, { gamma: '7.94' }], +}, { + featureType: 'landscape.natural.terrain', + elementType: 'geometry', + stylers: [{ visibility: 'on' }, { saturation: '94' }, { lightness: '-30' }, { gamma: '8.59' }, { weight: '5.38' }], +}, { + featureType: 'poi', + elementType: 'all', + stylers: [{ visibility: 'off' }], +}, { + featureType: 'poi.park', + elementType: 'geometry', + stylers: [{ saturation: '-26' }, { lightness: '20' }, { weight: '1' }, { gamma: '1' }], +}, { + featureType: 'poi.park', + elementType: 'geometry.fill', + stylers: [{ visibility: 'on' }], +}, { + featureType: 'road', + elementType: 'all', + stylers: [{ saturation: -100 }, { lightness: 45 }], +}, { + featureType: 'road', + elementType: 'geometry.fill', + stylers: [{ visibility: 'on' }, { color: '#fafafa' }], +}, { + featureType: 'road', + elementType: 'geometry.stroke', + stylers: [{ visibility: 'off' }], +}, { + featureType: 'road', + elementType: 'labels.text.fill', + stylers: [{ gamma: '0.95' }, { lightness: '3' }], +}, { + featureType: 'road', + elementType: 'labels.text.stroke', + stylers: [{ visibility: 'off' }], +}, { + featureType: 'road.highway', + elementType: 'all', + stylers: [{ visibility: 'simplified' }], +}, { + featureType: 'road.highway', + elementType: 'geometry', + stylers: [{ lightness: '100' }, { gamma: '5.22' }], +}, { + featureType: 'road.highway', + elementType: 'geometry.stroke', + stylers: [{ visibility: 'on' }], +}, { + featureType: 'road.arterial', + elementType: 'labels.icon', + stylers: [{ visibility: 'off' }], +}, { + featureType: 'transit', + elementType: 'all', + stylers: [{ visibility: 'off' }], +}, { + featureType: 'water', + elementType: 'all', + stylers: [{ color: '#b3dced' }, { visibility: 'on' }], +}, { + featureType: 'water', + elementType: 'labels.text.fill', + stylers: [{ visibility: 'on' }, { color: '#ffffff' }], +}, { + featureType: 'water', + elementType: 'labels.text.stroke', + stylers: [{ visibility: 'off' }, { color: '#e6e6e6' }], +}]; + +function decorateMap(parent) { + /* @formatter:off */ + const controls = div({ class: 'map-controls-wrapper' }, + div({ class: 'custom-controls' }, + a({ + class: 'map-style', + role: 'button', + 'aria-label': 'Satellite View', + }, + img({ src: '/icons/globe.png' }), + span({ class: 'satellite' }, 'Satellite'), + span({ class: 'map' }, 'Map'), + ), + a({ + class: 'map-draw-complete disabled', + role: 'button', + 'aria-label': 'Complete Draw', + }, + img({ src: '/icons/checkmark.svg' }), + span('Done')), + a({ + class: 'map-draw', + role: 'button', + 'aria-label': 'Draw', + }, + img({ class: 'draw', src: '/icons/pencil.svg' }), + img({ class: 'close', src: '/icons/close-x.svg' }), + span({ class: 'draw' }, 'Draw'), + span({ class: 'close' }, 'Close'), + ), + div({ class: 'zoom-controls' }, + a({ class: 'zoom-in', role: 'button', 'aria-label': 'Zoom In' }), + a({ class: 'zoom-out', role: 'button', 'aria-label': 'Zoom Out' }), + ), + ), + ); + const info = div({ class: 'map-draw-info' }, + p({ class: 'map-draw-tooltip' }, 'Click points on the map to draw your search.'), + p({ class: 'map-draw-boundary-link' }, + a({ role: 'button', 'aria-label': 'Remove Map Boundary' }, 'Add Map Boundary'), + ), + ); + + /* @formatter:on */ + parent.append(controls, info); +} + +async function boundsChanged() { + if (polygon) return; + window.clearTimeout(boundsTimeout); + boundsTimeout = window.setTimeout(() => { + const bounds = gmap.getBounds(); + const search = new BoxSearch(); + search.populateFromURLSearchParameters(new URLSearchParams(window.location.search)); + search.maxLat = bounds.getNorthEast().lat(); + search.maxLon = bounds.getNorthEast().lng(); + search.minLat = bounds.getSouthWest().lat(); + search.minLon = bounds.getSouthWest().lng(); + window.dispatchEvent(new CustomEvent(UPDATE_SEARCH_EVENT, { detail: { search } })); + }, 1000); +} + +function doneDrawing() { + polygon = closePolygon(); + drawingClickListener.remove(); + gmap.setOptions({ gestureHandling: 'cooperative' }); + document.querySelector('.property-search-results.block .search-map-container').classList.remove('drawing'); + const search = new PolygonSearch(); + search.populateFromURLSearchParameters(new URLSearchParams(window.location.search)); + polygon.getPath().forEach((latLng) => { + search.addPoint({ lat: latLng.lat(), lon: latLng.lng() }); + }); + window.dispatchEvent(new CustomEvent(UPDATE_SEARCH_EVENT, { detail: { search } })); +} + +function endDrawing() { + clearPolygon(); + polygon = undefined; + drawingClickListener.remove(); + gmap.setOptions({ gestureHandling: 'cooperative' }); + document.querySelector('.property-search-results.block .search-map-container').classList.remove('drawing'); + boundsChanged(); +} + +function drawingClickHandler(e) { + if (addPolygonPoint(e.latLng)) { + doneDrawing(); + } +} + +function startDrawing() { + gmap.setOptions({ gestureHandling: 'none' }); + drawingClickListener = gmap.addListener('click', drawingClickHandler); + startPolygon(gmap); +} + +function observeControls(block) { + block.querySelector('.search-map-container a.map-draw').addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const start = e.currentTarget.closest('.search-map-container').classList.toggle('drawing'); + if (start) { + startDrawing(gmap); + } else { + endDrawing(); + } + }); + + block.querySelector('.search-map-container a.map-draw-complete').addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + doneDrawing(); + }); + + block.querySelector('.search-map-container a.map-style').addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + e.currentTarget.closest('.search-map-container').classList.toggle('map'); + e.currentTarget.closest('.search-map-container').classList.toggle('satellite'); + const type = gmap.getMapTypeId(); + if (type === google.maps.MapTypeId.ROADMAP) { + gmap.setMapTypeId(google.maps.MapTypeId.SATELLITE); + } else { + gmap.setMapTypeId(google.maps.MapTypeId.ROADMAP); + } + }); + + block.querySelector('.search-map-container a.zoom-in').addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + gmap.setZoom(gmap.getZoom() + 1); + }); + + block.querySelector('.search-map-container a.zoom-out').addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + gmap.setZoom(gmap.getZoom() - 1); + }); +} + +function clearMarkers() { + clearInfos(); + clusterer.clearMarkers(); + if (mapMarkers.length) { + mapMarkers.forEach((marker) => { + marker.setVisible(false); + marker.setMap(null); + }); + } + mapMarkers.length = 0; +} + +function getMarkerBounds(markers) { + const bounds = new google.maps.LatLngBounds(); + markers.forEach((m) => { + bounds.extend(m.getPosition()); + }); + return bounds; +} + +/** + * Updates the map view with the new results. + * @param results + */ +async function displayResults(results) { + renderInProgress = true; + // Map isn't loaded yet. + if (!gmap) { + window.setTimeout(() => { + displayResults(results); + }, 1000); + return; + } + + // Map needs to be initialized. + if (gmap.getRenderingType() === google.maps.RenderingType.UNINITIALIZED) { + gmap.addListener('renderingtype_changed', () => { + displayResults(results); + }); + return; + } + // Clear any info windows + // Clear any existing Markers. + if (!results.properties || results.properties.length === 0) return; + + clearMarkers(); + + if (results.clusters.length) { + mapMarkers.push(...(await displayClusters(gmap, results.clusters))); + } else if (results.pins.length) { + mapMarkers.push(...(await displayPins(gmap, results.pins))); + clusterer.addMarkers(mapMarkers); + } + + const bounds = getMarkerBounds(mapMarkers); + if (!gmap.getBounds().contains(bounds.getCenter())) { + gmap.fitBounds(bounds); + } + renderInProgress = false; +} + +/** + * Reinitialize the Map based on history navigation. + * + * @param search the search that will be performed. + */ +function reinitMap(search) { + clearMarkers(); + clearPolygon(); + if (search instanceof PolygonSearch) { + startPolygon(gmap); + search.points.forEach((point) => { + const { lat, lon } = point; + addPolygonPoint(new google.maps.LatLng({ lat: parseFloat(lat), lng: parseFloat(lon) })); + }); + polygon = closePolygon(); + } +} + +/** + * Loads Google Maps and draws the initial view. + * @param block + * @return {Promise} + */ +async function initMap(block, search) { + const ele = block.querySelector('#gmap-canvas'); + + gmap = new google.maps.Map(ele, { + zoom, + maxZoom, + center: { lat: 41.24216, lng: -96.207990 }, + mapTypeId: google.maps.MapTypeId.ROADMAP, + clickableIcons: false, + gestureHandling: 'cooperative', + styles: MAP_STYLE, + visualRefresh: true, + disableDefaultUI: true, + }); + + decorateMap(block.querySelector('.search-results-map')); + block.querySelector('.search-map-wrapper').append(div({ class: 'mobile-info-window info-window' })); + observeControls(block); + + gmap.addListener('click', () => { + hideInfos(); + }); + + // Don't use 'bounds_changed' event for updating results - fires too frequently. + gmap.addListener('dragend', () => { + hideInfos(); + boundsChanged(); + }); + gmap.addListener('dblclick', () => { + hideInfos(); + boundsChanged(); + }); + + gmap.addListener('zoom_changed', () => { + if (renderInProgress) { + return; + } + hideInfos(); + boundsChanged(); + }); + + clusterer = getMarkerClusterer(gmap); + if (search instanceof PolygonSearch) { + startPolygon(gmap); + search.points.forEach((point) => { + const { lat, lon } = point; + addPolygonPoint(new google.maps.LatLng({ lat: parseFloat(lat), lng: parseFloat(lon) })); + }); + polygon = closePolygon(); + } +} + +// Anytime a search is performed, hide any existing markers. +// window.addEventListener(EVENT_NAME, hideMarkers); + +/* Load all the map libraries here */ +loadMaps(); +await google.maps.importLibrary('core'); +await google.maps.importLibrary('maps'); +await google.maps.importLibrary('marker'); +await loadScript('https://unpkg.com/@googlemaps/markerclusterer/dist/index.min.js', { type: 'application/javascript' }); +await loadScript('https://unpkg.com/jsts/dist/jsts.min.js', { type: 'application/javascript' }); +export { + displayResults, + initMap, + reinitMap, +}; diff --git a/blocks/property-search-results/map/clusters.js b/blocks/property-search-results/map/clusters.js new file mode 100644 index 00000000..79c0b65d --- /dev/null +++ b/blocks/property-search-results/map/clusters.js @@ -0,0 +1,73 @@ +/* global google */ + +import Search, { UPDATE_SEARCH_EVENT } from '../../../scripts/apis/creg/search/Search.js'; +import { DRAWING_ENDED, DRAWING_STARTED } from './drawing.js'; + +let drawing = false; + +const clusterClickHandler = async (map, cluster) => { + if (drawing) return; + const center = new google.maps.LatLng(cluster.centerLat, cluster.centerLon); + map.panTo(center); + const search = await Search.load('Box'); + search.populateFromURLSearchParameters(new URLSearchParams(window.location.search)); + search.minLat = cluster.swLat; + search.minLon = cluster.swLon; + search.maxLat = cluster.neLat; + search.maxLon = cluster.neLon; + window.dispatchEvent(new CustomEvent(UPDATE_SEARCH_EVENT, { detail: { search } })); +}; + +const compensateForCluster = (count) => { + let increase = 0; + const log = Math.log10(count); + increase = 6 * (Math.round(log * 2) / 2); + return increase; +}; + +/** + * Create a cluster marker from a search result cluster. + * @param {Object} cluster + * @param {Number} cluster.centerLat Latitude center of the cluster + * @param {Number} cluster.centerLon Longitude center of the cluster + * @param {Number} cluster.count Number of properties in cluster. + */ +export function createClusterMaker(cluster) { + // const dimensions = { width: 30, height: 30 }; + const sizeCompensation = compensateForCluster(cluster.count); + const size = 30 + sizeCompensation; + const icon = { + url: '/icons/maps/map-clusterer-blue.png', + scaledSize: new google.maps.Size(size, size), + anchor: new google.maps.Point(size / 2, size / 2), + }; + + return new google.maps.Marker({ + position: new google.maps.LatLng(cluster.centerLat, cluster.centerLon), + zIndex: cluster.count, + icon, + label: { + fontFamily: 'sans-serif', + fontSize: '12px', + text: `${cluster.count}`, + color: 'white', + className: 'no-class', + }, + }); +} + +export default async function displayClusters(map, clusters) { + map.getDiv().addEventListener(DRAWING_STARTED, () => { drawing = true; }); + map.getDiv().addEventListener(DRAWING_ENDED, () => { drawing = false; }); + + const markers = []; + clusters.forEach((cluster) => { + const marker = createClusterMaker(cluster); + marker.setMap(map); + marker.addListener('click', () => { + clusterClickHandler(marker.map, cluster); + }); + markers.push(marker); + }); + return markers; +} diff --git a/blocks/property-search-results/map/drawing.js b/blocks/property-search-results/map/drawing.js new file mode 100644 index 00000000..81d870f2 --- /dev/null +++ b/blocks/property-search-results/map/drawing.js @@ -0,0 +1,150 @@ +/* global google */ +/* global jsts */ + +const DRAWING_STARTED = 'MapDrawingStarted'; +const DRAWING_ENDED = 'MapDrawingEnded'; + +let gmap; + +let mouseListener; +const lines = []; +let activeLine; +let polygon; + +function mouseHandler(e) { + if (activeLine.getPath().length) { + activeLine.getPath().setAt(1, e.latLng); + } +} + +/** + * Check if the line intersects any of the existing ones. + * @param {Array} existing list of existing lines + * @param {google.map.Polyline} potential potential line that may intersect + * @return {boolean} whether or not the line intersects + */ +function intersects(existing, potential) { + const coords = []; + if (existing.length) { + coords.push(new jsts.geom.Coordinate(existing[0].getPath().getAt(0).lat(), existing[0].getPath().getAt(0).lng())); + existing.forEach((line) => { + coords.push(new jsts.geom.Coordinate(line.getPath().getAt(1).lat(), line.getPath().getAt(1).lng())); + }); + coords.push(new jsts.geom.Coordinate(potential.getPath().getAt(1).lat(), potential.getPath().getAt(1).lng())); + const simple = jsts.operation.IsSimpleOp.isSimple(new jsts.geom.GeometryFactory().createLineString(coords)); + return !simple; // Simple LineStrings do not intersect. + } + return false; +} + +/** + * Clears the map of any lines or polygon + */ +function clearPolygon() { + lines.forEach((l) => { l.setMap(null); }); + lines.length = 0; + if (activeLine) { + activeLine.getPath().clear(); + activeLine.setMap(null); + } + if (polygon) { + polygon.setMap(null); + polygon = undefined; + } +} + +/** + * Closes the active polygon, creating the instance and returning it. + * @return {google.maps.Polygon} the completed polygon + */ +function closePolygon() { + // eslint-disable-next-line no-unused-expressions + mouseListener && mouseListener.remove(); + + activeLine.getPath().clear(); + activeLine.setMap(null); + if (lines.length === 1) { + lines[0].setMap(null); + lines.length = 0; + } + + const points = []; + points.push(lines[0].getPath().getAt(0)); + lines.forEach((line) => { + points.push(line.getPath().getAt(1)); + }); + polygon = new google.maps.Polygon({ + map: gmap, + paths: points, + strokeColor: '#BA9BB2', + strokeWeight: 2, + clickable: false, + fillOpacity: 0, + }); + return polygon; +} + +/** + * Adds a point to the polygon. + * If the next line intersects an existing line, it closes the polygon and returns it. + * @param {google.maps.LatLng} point + * @return {boolean} if adding this point closed the polygon + */ +function addPolygonPoint(point) { + const prev = activeLine.getPath().getAt(0); + if (prev) { + const line = new google.maps.Polyline({ + clickable: false, + strokeColor: '#BA9BB2', + strokeWeight: 2, + path: [prev, point], + }); + if (intersects(lines, line)) { + return true; + } + line.setMap(gmap); + lines.push(line); + } + if (lines.length > 1) { + document.querySelector('.property-search-results.block .search-map-container .custom-controls .map-draw-complete.disabled')?.classList.remove('disabled'); + } + activeLine.getPath().setAt(0, point); + return false; +} + +/** + * Starts drawing the polygon by adding a line to the map. + * Also initiates the tracking line for potential next line in polygon definition. + * + * @param {google.maps.Map} map + */ +function startPolygon(map) { + clearPolygon(); + gmap = map; + activeLine = new google.maps.Polyline({ + map: gmap, + clickable: false, + strokeOpacity: 0, + icons: [{ + icon: { + path: 'M 0,-1 0,1', + strokeOpacity: 1, + scale: 2, + strokeColor: '#BA9BB2', + }, + offset: 0, + repeat: '20px', + }], + }); + mouseListener = gmap.addListener('mousemove', mouseHandler); + gmap.getDiv().dispatchEvent(new CustomEvent(DRAWING_STARTED)); +} + +export { + DRAWING_STARTED, + DRAWING_ENDED, + startPolygon, + addPolygonPoint, + closePolygon, + clearPolygon, +}; diff --git a/blocks/property-search-results/map/pins.js b/blocks/property-search-results/map/pins.js new file mode 100644 index 00000000..22988f30 --- /dev/null +++ b/blocks/property-search-results/map/pins.js @@ -0,0 +1,342 @@ +/* global google */ +/* global markerClusterer */ + +import { formatPrice } from '../../../scripts/util.js'; +import { createClusterMaker } from './clusters.js'; +import { BREAKPOINTS } from '../../../scripts/scripts.js'; +import { getDetails } from '../../../scripts/apis/creg/creg.js'; +import { + a, p, div, img, span, domEl, +} from '../../../scripts/dom-helpers.js'; +import { DRAWING_ENDED, DRAWING_STARTED } from './drawing.js'; + +let drawing; +let moTimeout; +let moController; + +const infoWindows = []; + +function createInfo(property) { + const href = property.PdpPath.includes('www.commonmoves.com') ? `/property/detail/pid-${property.ListingId}` : property.PdpPath; + const providers = []; + if (property.propertyProviders || property.originatingSystemName) { + providers.push('Listing Provided by: '); + providers.push(property.propertyProviders || property.originatingSystemName); + } + + const details = div({ class: 'details' }, + div({ class: 'address' }, + span({ class: 'street' }, property.StreetName || ''), + span({ class: 'locality' }, `${`${property.City}, ` || ' '} ${`${property.StateOrProvince} ` || ' '} ${property.PostalCode || ''}`), + ), + ); + + if (property.sellingOfficeName) { + const address = details.querySelector('.address'); + address.prepend( + span({ class: 'danger' }, `${property.mlsStatus || ''} ${property.ClosedDate || ''}`), + ); + } + + if (property.municipality) { + details.append(span({ class: 'municipality' }, property.municipality)); + } + + details.append(span({ class: 'providers' }, ...providers)); + + const listing = div({ class: 'listing-info' }); + if (property.addMlsFlag === 'true') { + listing.append(span({ class: 'mls' }, `MLS ID: ${property.ListingId}`)); + } + if (property.CourtesyOf) { + listing.append(span({ class: 'courtesy' }, `Listing courtesy of: ${property.CourtesyOf}`)); + } + if (property.sellingOfficeName) { + listing.append(span({ class: 'courtesy' }, `Listing sold by: ${property.sellingOfficeName}`)); + } + if (property.addMlsFlag && property.listAor) { + const aor = div({ class: 'aor' }, span(`Listing provided by: ${property.listAor}`)); + if (property.brImageUrl) { + aor.append(span({ class: 'aor-img' }, img({ src: property.brImageUrl }))); + } + listing.append(aor); + } + + const image = div({ class: 'image-wrapper' }, + div({ class: 'luxury' }, span('Luxury Collection')), + img({ src: property.smallPhotos[0]?.mediaUrl }), + ); + const info = div({ class: 'info' }, + div({ class: 'price' }, + span({ class: 'us' }, property.ListPriceUS || ''), + span({ class: 'alt' }, property.listPriceAlternateCurrency || ''), + ), + div({ class: 'property-buttons' }, + a({ class: 'contact-us', 'aria-label': `Contact us about ${property.StreetName}` }, + span({ class: 'icon icon-envelope' }, img({ + 'data-icon-name': 'envelope', + src: '/icons/envelope.svg', + alt: 'envelope', + })), + span({ class: 'icon icon-envelopedark' }, img({ + 'data-icon-name': 'envelopedark', + src: '/icons/envelopedark.svg', + alt: 'envelope', + })), + ), + a({ class: 'save', 'aria-label': `Save ${property.StreetName}` }, + span({ class: 'icon icon-heartempty' }, img({ + 'data-icon-name': 'heartempty', + src: '/icons/heartempty.svg', + alt: 'heart', + })), + span({ class: 'icon icon-heartemptydark' }, img({ + 'data-icon-name': 'heartemptydark', + src: '/icons/heartemptydark.svg', + alt: 'heart', + })), + span({ class: 'icon icon-heartfull' }, img({ + 'data-icon-name': 'heartfull', + src: '/icons/heartfull.svg', + alt: 'heart', + })), + ), + ), + details, + domEl('hr'), + listing, + ); + + return a({ class: 'info-wrapper', rel: 'noopener', href }, image, info); +} + +/** + * Removes all Info Windows that may be on the map or attached to markers. + */ +function clearInfos() { + document.querySelector('.property-search-results.block .mobile-info-window').replaceChildren(); + infoWindows.forEach((iw) => { + iw.close(); + iw.visible = false; + }); + infoWindows.length = 0; +} + +/** + * Hides any visible Info Windows on the map. + */ +function hideInfos() { + document.querySelector('.property-search-results.block .mobile-info-window').replaceChildren(); + infoWindows.forEach((iw) => { + if (iw.visible) { + iw.close(); + iw.visible = false; + } + }); +} + +async function clusterMouseHandler(marker, cluster) { + moController?.abort(); + moController = new AbortController(); + const controller = moController; + if (marker.infoWindow?.visible) { + return; + } + hideInfos(); + if (!marker.infoWindow && !controller.signal.aborted) { + const content = div({ class: 'info-window cluster' }, div({ class: 'loading' }, p('Loading...'))); + const tmp = new google.maps.InfoWindow({ content }); + tmp.open({ anchor: marker, shouldFocus: false }); + const center = marker.getMap().getCenter(); + // But if this fetch was canceled, don't show the info window. + const ids = []; + cluster.markers.forEach((m) => { + ids.push(m.listingKey); + }); + await getDetails(...ids).then((listings) => { + // If we got this far, may as well add the content to info window. + const infos = []; + listings.forEach((property) => { + infos.push(createInfo(property)); + }); + content.replaceChildren(...infos); + const iw = new google.maps.InfoWindow({ content }); + iw.setContent(content); + iw.addListener('close', () => marker.getMap().panTo(center)); + infoWindows.push(iw); + marker.infoWindow = iw; + tmp.close(); + }); + } + if (controller.signal.aborted) { + return; + } + marker.infoWindow.open({ anchor: marker, shouldFocus: false }); + marker.infoWindow.visible = true; +} + +/** + * Display the Mobile Info Window with the desired content. + * @param {Promise>} promise a promise that resolves to the content to display + * @param cluster flag to indicate if this is a list of properties + */ +async function showMobileInfoWindow(promise, cluster = false) { + window.scrollTo({ top: 115, behavior: 'smooth' }); + const iw = document.querySelector('.property-search-results.block .search-map-wrapper .mobile-info-window'); + if (cluster) iw.classList.add('cluster'); + iw.replaceChildren(div({ class: 'loading' }, p('Loading...'))); + promise.then((content) => { + // eslint-disable-next-line no-param-reassign + content = content.length ? content : [content]; + iw.replaceChildren(...content); + }); +} + +/* + See https://googlemaps.github.io/js-markerclusterer/interfaces/MarkerClustererOptions.html#renderer + */ +const ClusterRenderer = { + render: (cluster) => { + const marker = createClusterMaker({ + centerLat: cluster.position.lat(), + centerLon: cluster.position.lng(), + count: cluster.count, + }); + + // Do not fire the fetch immediately, give the user a beat to move their mouse to desired target. + marker.addListener('mouseout', () => window.clearTimeout(moTimeout)); + marker.addListener('mouseover', () => { + if (drawing) return; + if (BREAKPOINTS.medium.matches) { + moTimeout = window.setTimeout(() => clusterMouseHandler(marker, cluster), 500); + } + }); + return marker; + }, +}; + +function pinGroupClickHandler(e, cluster) { + if (drawing) return; + if (BREAKPOINTS.medium.matches && e.domEvent instanceof TouchEvent) { + clusterMouseHandler(cluster.marker, cluster); + } else { + const listings = cluster.markers.map((m) => m.listingKey); + const promise = getDetails(...listings).then((details) => { + const links = []; + details.forEach((property) => { + links.push(createInfo(property)); + }); + return links; + }); + showMobileInfoWindow(promise, true); + } +} + +/** + * Generate a new Marker Clusterer from the map. + * @param map + * @return {markerClusterer.MarkerClusterer} + */ +function getMarkerClusterer(map) { + return new markerClusterer.MarkerClusterer({ map, renderer: ClusterRenderer, onClusterClick: pinGroupClickHandler }); +} + +async function pinMouseHandler(marker, pin) { + moController?.abort(); + moController = new AbortController(); + const controller = moController; + if (marker.infoWindow?.visible) { + return; + } + hideInfos(); + if (!marker.infoWindow && !controller.signal.aborted) { + const content = div({ class: 'info-window' }, div({ class: 'loading' }, p('Loading...'))); + const tmp = new google.maps.InfoWindow({ content }); + tmp.open({ anchor: marker, shouldFocus: false }); + const center = marker.getMap().getCenter(); + // But if this fetch was canceled, don't show the info window. + await getDetails(pin.listingKey).then((listings) => { + content.replaceChildren(createInfo(listings[0])); + const iw = new google.maps.InfoWindow({ content }); + iw.setContent(content); + iw.addListener('close', () => marker.getMap().panTo(center)); + infoWindows.push(iw); + marker.infoWindow = iw; + tmp.close(); + }); + } + if (controller.signal.aborted) { + return; + } + marker.infoWindow.open({ anchor: marker, shouldFocus: false }); + marker.infoWindow.visible = true; +} + +/** + * Create a cluster marker from a search result cluster. + * @param {Object} pin + * @param {Number} pin.lat Latitude of the pin + * @param {Number} pin.lon Longitude of the pin + * @param {Number} pin.price Price of the pin listing + * @param {Number} pin.listingKey Listing id of the pin + * @param {Number} pin.officeCode Office code of the listing. + */ +function createPinMarker(pin) { + const icon = { + url: '/icons/maps/map-marker-standard.png', + scaledSize: new google.maps.Size(50, 25), + anchor: new google.maps.Point(25, 0), + }; + + const marker = new google.maps.Marker({ + position: new google.maps.LatLng(pin.lat, pin.lon), + zIndex: 1, + icon, + label: { + fontFamily: 'sans-serif', + fontSize: '12px', + text: `$${formatPrice(pin.price)}`, + color: 'white', + className: 'no-class', + }, + }); + marker.addListener('click', (e) => { + if (drawing) return; + if (BREAKPOINTS.medium.matches && e.domEvent instanceof TouchEvent) { + pinMouseHandler(marker, pin); + } else { + showMobileInfoWindow(getDetails(pin.listingKey).then((details) => createInfo(details[0]))); + } + }); + // Do not fire the fetch immediately, give the user a beat to move their mouse to desired target. + marker.addListener('mouseout', () => window.clearTimeout(moTimeout)); + marker.addListener('mouseover', () => { + if (drawing) return; + if (BREAKPOINTS.medium.matches) { + moTimeout = window.setTimeout(() => pinMouseHandler(marker, pin), 500); + } + }); + + marker.listingKey = pin.listingKey; + return marker; +} + +async function displayPins(map, pins) { + map.getDiv().addEventListener(DRAWING_STARTED, () => { drawing = true; }); + map.getDiv().addEventListener(DRAWING_ENDED, () => { drawing = false; }); + + const markers = []; + pins.forEach((pin) => { + const marker = createPinMarker(pin); + marker.setMap(map); + markers.push(marker); + }); + return markers; +} + +export { + clearInfos, + hideInfos, + getMarkerClusterer, + displayPins, +}; diff --git a/blocks/property-search-results/observers.js b/blocks/property-search-results/observers.js new file mode 100644 index 00000000..5b513a7f --- /dev/null +++ b/blocks/property-search-results/observers.js @@ -0,0 +1,87 @@ +import Search, { UPDATE_SEARCH_EVENT } from '../../scripts/apis/creg/search/Search.js'; +import { input } from '../../scripts/dom-helpers.js'; +import ListingType from '../../scripts/apis/creg/search/types/ListingType.js'; +import { closeOnBodyClick } from '../shared/search/util.js'; + +export default function observe(block) { + block.querySelectorAll('a.map-view').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const blk = e.currentTarget.closest('.block'); + blk.classList.remove('list-view'); + blk.classList.add('map-view'); + }); + }); + + block.querySelectorAll('a.list-view').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const blk = e.currentTarget.closest('.block'); + blk.classList.remove('map-view'); + blk.classList.add('list-view'); + }); + }); + + block.querySelectorAll('.listing-types .filter-toggle').forEach((t) => { + t.addEventListener('click', async (e) => { + e.preventDefault(); + const { currentTarget } = e; + const search = await Search.fromQueryString(window.location.search); + const checked = currentTarget.querySelector('div.checkbox').classList.toggle('checked'); + const ipt = currentTarget.querySelector('input'); + input.checked = checked; + if (checked) { + search.addListingType(ipt.value); + } else { + search.removeListingType(ipt.value); + } + window.dispatchEvent(new CustomEvent(UPDATE_SEARCH_EVENT, { detail: { search } })); + }); + }); + + block.querySelector('.listing-types').addEventListener('click', (e) => { + e.preventDefault(); + const ipt = e.target.closest('.filter-toggle')?.querySelector('input'); + if (ipt && ipt.value === ListingType.FOR_RENT.type) { + e.currentTarget.querySelector(`input[value="${ListingType.PENDING.type}"]`).closest('.filter-toggle').classList.toggle('disabled'); + } else if (ipt && ipt.value === ListingType.PENDING.type) { + e.currentTarget.querySelector(`input[value="${ListingType.FOR_RENT.type}"]`).closest('.filter-toggle').classList.toggle('disabled'); + } + }); + + const sortSelect = block.querySelector('.sort-options .select-wrapper div.selected'); + sortSelect.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const button = e.currentTarget; + const wrapper = button.closest('.select-wrapper'); + const open = wrapper.classList.toggle('open'); + button.setAttribute('aria-expanded', open); + if (open) { + closeOnBodyClick(wrapper); + } + }); + + sortSelect.querySelectorAll('.select-items li').forEach((opt) => { + opt.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + const target = e.currentTarget; + const value = target.getAttribute('data-value'); + const wrapper = target.closest('.select-wrapper'); + wrapper.querySelector('.selected span').textContent = target.textContent; + wrapper.querySelector('ul li.selected')?.classList.toggle('selected'); + const selected = wrapper.querySelector(`select option[value="${value}"]`); + selected.selected = true; + target.classList.add('selected'); + wrapper.classList.remove('open'); + wrapper.querySelector('[aria-expanded="true"]')?.setAttribute('aria-expanded', 'false'); + const search = await Search.fromQueryString(window.location.search); + search.sortBy = selected.getAttribute('data-sort-by'); + search.sortDirection = selected.getAttribute('data-sort-direction'); + window.dispatchEvent(new CustomEvent(UPDATE_SEARCH_EVENT, { detail: { search } })); + }); + }); +} diff --git a/blocks/property-search-results/property-search-results.css b/blocks/property-search-results/property-search-results.css new file mode 100644 index 00000000..425edb6a --- /dev/null +++ b/blocks/property-search-results/property-search-results.css @@ -0,0 +1,502 @@ +@import url('../shared/property/cards.css'); +@import url('map.css'); + + +/* Override global settings */ +main .section.property-search-results-container { + padding: 0; + margin: 0 auto; +} + +.property-search-results-wrapper { + padding: 0; + margin: 0; +} + +/* End global overrides */ + +.property-search-results.block { + position: relative; + min-height: calc(100vh - var(--nav-height) - 50px - 40px); + + --map-height: calc(100vh - var(--nav-height) - 50px - 75px - 55px); /* Nav, search bar, sort options, mobile links */ +} + +.property-search-results.block.map-view .search-map-wrapper { + height: var(--map-height); +} + +.property-search-results.block .search-results-loader { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + top: 0; + left: 50%; + height: 100%; + width: 100%; + max-width: 400px; + transform: translateX(-50%); + margin: 0 auto; + opacity: 0; + visibility: hidden; + transition: all 2s linear; + z-index: 2 +} + +.property-search-results.block .loading .search-results-loader { + opacity: 1; + visibility: visible; + transition: all 2s linear; +} + +.property-search-results.block .search-results-loader .animation { + position: relative; + width: 100%; +} + +@keyframes pulse { + from { + border: 0 solid white; + } + + to { + border: 75px solid white + } +} + +.property-search-results.block .search-results-loader .pulse { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 50%; + overflow: hidden; + animation: linear 2s infinite alternate pulse; + z-index: 2; +} + +.property-search-results.block .search-results-loader picture { + position: relative; + display: block; + width: 100%; + padding-bottom: 100%; + border-radius: 50%; + overflow: hidden; +} + +.property-search-results.block .search-results-loader picture img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-position: center; + object-fit: cover; +} + +.property-search-results.block .search-results-content { + position: relative; +} + +.property-search-results.block .search-results-wrapper { + min-height: 400px; + padding: 10px; + opacity: 1; + visibility: visible; + transition: all 2s linear; +} + +.property-search-results.block .loading .search-results-wrapper { + opacity: 0; + visibility: hidden; + transition: all 2s linear; +} + +.property-search-results.block.list-view .search-map-wrapper { + display: none; +} + +.property-search-results.block.map-view .search-results-wrapper { + display: none; +} + +.property-search-results.block .view-options a { + padding: 0 15px; + height: 35px; + font-size: var(--body-font-size-xs); + letter-spacing: var(--letter-spacing-m); + line-height: 35px; + border: 1px solid var(--grey); + white-space: nowrap; +} + +.property-search-results.block.list-view .view-options a.list-view { + display: none; +} + +.property-search-results.block.map-view .view-options a.map-view { + display: none; +} + +.property-search-results.block .select-wrapper select { + display: none; +} + +.property-search-results.block .select-wrapper > .selected { + display: flex; + position: relative; + padding: 0 15px; + align-items: center; + justify-content: space-between; + gap: 3px; + width: 100%; + height: 35px; + min-width: 100px; + line-height: 35px; + color: var(--body-color); + border: 1px solid var(--grey); +} + +.property-search-results.block .select-wrapper > .selected::after { + display: block; + content: '\f0d7'; + font-family: var(--font-family-fontawesome); + color: var(--dark-grey); + text-align: right; + width: 15px; +} + +.property-search-results.block .select-wrapper.open > .selected::after { + content: '\f0d8'; +} + +.property-search-results.block .select-wrapper .select-items { + display: none; + position: absolute; + padding: 0; + max-height: 185px; + top: 100%; + left: -1px; + width: 145px; + overflow-y: scroll; + background-color: var(--white); + border: 1px solid var(--grey); + box-shadow: 0 .5rem 1rem rgba(0 0 0 / 15%); + white-space: nowrap; + z-index: 1; +} + +.property-search-results.block .select-wrapper.open .select-items { + display: block; +} + +.property-search-results.block .select-wrapper > .selected span { + overflow: hidden; + font-size: var(--body-font-size-xs); + font-weight: var(--font-weight-light); + line-height: var(--line-height-xl); + letter-spacing: var(--letter-spacing-m); + text-transform: uppercase; + white-space: nowrap; +} + +.property-search-results.block .select-wrapper .select-items li { + display: flex; + padding: 4px 15px; + cursor: pointer; + font-size: var(--body-font-size-xs); + color: var(--body-color); + text-transform: uppercase; + line-height: var(--line-height-xl); + letter-spacing: var(--letter-spacing-xs); +} + +.property-search-results.block .select-wrapper .select-items li:hover { + color: var(--body-color); + background-color: var(--light-grey); +} + +.property-search-results.block .select-wrapper .select-items li.selected { + color: var(--body-color); + background-color: var(--light-grey); +} + + +.property-search-results.block .property-search-filters { + display: grid; + grid-template-columns: 1fr; + padding: 0 15px; + gap: 30px; + margin: 20px 0; +} + +.property-search-results.block .listing-types { + display: none; + padding: 0 15px; + gap: 15px; +} + +.property-search-results.block .listing-types .filter-toggle { + display: flex; + gap: 10px; + align-items: center; + font-size: var(--body-font-size-s); +} + +.property-search-results.block .listing-types .filter-toggle label { + font-size: var(--body-font-size-xs); + letter-spacing: var(--letter-spacing-xs); + line-height: normal; + color: var(--body-color); +} + +.property-search-results.block .listing-types .filter-toggle .checkbox { + min-width: 24px; + height: 16px; + border-radius: 100px; + position: relative; + border: 1px solid #b4b4b4; + background: var(--white); +} + +.property-search-results.block .listing-types .filter-toggle .checkbox::before { + content: ''; + position: absolute; + right: 2px; + top: 2px; + height: 10px; + width: 10px; + border-radius: 10px; + z-index: 1; + background: #b4b4b4; +} + +.property-search-results.block .listing-types .filter-toggle .checkbox.checked { + background: var(--body-color); + border: 1px solid transparent +} + +.property-search-results.block .listing-types .filter-toggle .checkbox.checked::before { + transform: translateX(-8px); + background: var(--white); +} + +.property-search-results.block .listing-types .filter-toggle.disabled { + pointer-events: none; + opacity: .3; +} + +.property-search-results.block .sort-options { + display: flex; + align-items: center; + gap: 10px; + justify-self: flex-end; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.property-search-results.block .sort-options label { + font-size: var(--body-font-size-s); + line-height: var(--line-height-m); + color: var(--body-color); + white-space: nowrap; +} + +.property-search-results.block .sort-options .select-wrapper { + position: relative; + width: 145px; +} + +.property-search-results.block .search-results-content .search-results-disclaimer-wrapper { + margin: 30px 0; +} + +.property-search-results.block .search-results-disclaimer-wrapper .search-results-disclaimer { + margin: 15px 0; + padding: 0 16px; +} + +.property-search-results.block .search-results-disclaimer > div { + margin: 16px 0; +} + +.property-search-results.block .search-results-disclaimer p { + margin: 5px 0; + font-size: var(--body-font-size-xs); + line-height: var(--line-height-xs); + letter-spacing: var(--letter-spacing-s); + color: var(--dark-grey); +} + +.property-search-results.block .search-results-disclaimer p.image:not(.img-first) { + line-height: 0; + margin: 5px auto; + text-align: center; +} + +.property-search-results.block .search-results-disclaimer p.image img { + height: auto; + width: auto; + max-width: 140px; + max-height: 30px; +} + +.property-search-results.block .pagination-wrapper .select-wrapper { + position: relative; + width: 145px; +} + +.property-search-results.block .search-results-pagination .pagination-wrapper { + display: flex; + margin-top: 30px; + align-items: center; + justify-content: flex-end; + gap: 15px; +} + +.property-search-results.block .search-results-wrapper .search-results-pagination .link-wrapper { + display: flex; + gap: 15px; +} + +.property-search-results.block .search-results-wrapper .search-results-pagination .link-wrapper a { + height: 35px; + width: 35px; + border: 1px solid var(--grey); +} + +.property-search-results.block .search-results-wrapper .search-results-pagination .link-wrapper a.disabled { + pointer-events: none; + border: 1px solid var(--platinum); +} + +.property-search-results.block .search-results-wrapper .search-results-pagination .link-wrapper a svg { + padding: 5px; + height: 100%; + width: 100%; +} + +.property-search-results.block .search-results-wrapper .search-results-pagination .link-wrapper a.disabled svg { + filter: invert(99%) sepia(0%) saturate(1103%) hue-rotate(210deg) brightness(113%) contrast(81%); +} + + +.property-search-results.block .search-results-wrapper .search-results-pagination .link-wrapper a.prev svg { + transform: rotate(-180deg); +} + + +.property-search-results.block .view-options p { + margin: 0; +} + +.property-search-results.block .mobile-view-options { + position: sticky; + bottom: 0; + padding: 10px 0; + box-shadow: 0 0 6px 0 rgba(0 0 0 / 23%); + background-color: var(--white); + z-index: 100; +} + +.property-search-results.block .mobile-view-options p { + display: flex; + justify-content: center; + gap: 16px; +} + +.property-search-results.block .desktop-view-options { + display: none; +} + +/** Override the default card display */ + +.property-search-results.block .search-results-wrapper .property-list-cards { + display: grid; + grid-template-columns: 1fr; + gap: 30px; +} + +.property-search-results.block .search-results-wrapper .property-list-cards .listing-tile { + width: 100%; + max-width: unset; +} + +@media screen and (min-width: 600px) { + .property-search-results.block .search-results-wrapper .property-list-cards { + grid-template-columns: repeat(3, 1fr); + gap: 15px; + } +} + +@media screen and (min-width: 900px) { + .property-search-results.block.map-view { + display: grid; + grid-template: + "filters filters" + "map results" + / 58% 1fr; + } + + .property-search-results.block .property-search-filters { + grid-area: filters; + grid-template-columns: 1fr min-content min-content; + } + + .property-search-results.block .search-map-wrapper { + grid-area: map; + overflow: scroll; + } + + .property-search-results.block .search-results-content { + padding: 0; + grid-area: results; + max-height: var(--map-height); + overflow: scroll; + } + + /* Override the Property Bar listing type section when this block's are visible */ + .property-search-bar.block .listing-types { + display: none; + } + + .property-search-results.block .desktop-view-options { + display: flex; + align-items: center; + } + + .property-search-results.block .listing-types { + display: flex; + } + + .property-search-results.block .mobile-view-options { + display: none; + } + + .property-search-results.block .search-results-wrapper { + padding: 0 10px; + } + + .property-search-results.block.map-view .search-results-wrapper { + display: block; + } + + .property-search-results.block .search-results-wrapper .property-list-cards { + grid-template-columns: repeat(3, 1fr); + } + + .property-search-results.block.map-view .search-results-wrapper .property-list-cards { + grid-template-columns: 1fr + } +} + +@media screen and (min-width: 1200px) { + .property-search-results.block .search-results-wrapper .property-list-cards { + grid-template-columns: repeat(4, 1fr); + } + + .property-search-results.block.map-view .search-results-wrapper .property-list-cards { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/blocks/property-search-results/property-search-results.js b/blocks/property-search-results/property-search-results.js new file mode 100644 index 00000000..7a12daad --- /dev/null +++ b/blocks/property-search-results/property-search-results.js @@ -0,0 +1,264 @@ +import { + a, div, input, label, li, option, p, select, span, ul, img, domEl, +} from '../../scripts/dom-helpers.js'; +import Search, { + UPDATE_SEARCH_EVENT, + SEARCH_URL, + STORAGE_KEY, +} from '../../scripts/apis/creg/search/Search.js'; +import ListingType from '../../scripts/apis/creg/search/types/ListingType.js'; +import { propertySearch } from '../../scripts/apis/creg/creg.js'; +import { getMetadata, readBlockConfig } from '../../scripts/aem.js'; +import { updateForm } from '../property-search-bar/delayed.js'; +import { BREAKPOINTS } from '../../scripts/scripts.js'; +import observe from './observers.js'; +import loader from './loader.js'; +import { displayResults as displayList } from './results.js'; +import { displayResults as displayMap, reinitMap } from './map.js'; + +let searchController; + +let initMap; + +/** + * Converts the Disclaimer returned from search results and extracts images and text. + * @param {String} disclaimer + */ +function sanitizeDisclaimer(disclaimer) { + const tmp = document.createElement('div'); + tmp.innerHTML = disclaimer; + const content = []; + tmp.querySelectorAll(':scope > div').forEach((d) => { + const text = []; + let image; + let imgFirst = false; + // eslint-disable-next-line no-bitwise + const walker = document.createTreeWalker(d, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + let next = walker.firstChild(); + while (next) { + if (next.nodeType === Node.TEXT_NODE && next.textContent.trim() !== '') { + text.push(p(next.textContent)); + } else if (next.nodeName === 'IMG') { + if (text.length === 0) imgFirst = true; + const config = { src: next.src }; + if (next.height) config.height = next.height; + if (next.width) config.width = next.width; + image = p({ class: `image ${imgFirst ? 'img-first' : ''}` }, img(config)); + } + next = walker.nextNode(); + } + if (image) { + if (imgFirst) { + text.unshift(image); + } else { + text.push(image); + } + } + content.push(div(...text)); + }); + return content; +} + +function updateFilters(search) { + const filters = document.querySelector('.property-search-results.block .property-search-filters'); + filters.querySelectorAll('.listing-types .filter-toggle.disabled').forEach((t) => t.classList.remove('disabled')); + filters.querySelectorAll('.listing-types .filter-toggle input[type="checkbox"]').forEach((c) => { + c.removeAttribute('checked'); + c.nextElementSibling.classList.remove('checked'); + }); + search.listingTypes.forEach((t) => { + const chkbx = filters.querySelector(`.listing-types .filter-toggle input[name="${t.type}"]`); + chkbx.setAttribute('checked', 'checked'); + chkbx.nextElementSibling.classList.add('checked'); + if (t.type === ListingType.FOR_RENT.type) { + filters.querySelector(`.listing-types .filter-toggle input[name="${ListingType.PENDING.type}"]`).closest('.filter-toggle').classList.add('disabled'); + } else if (t.type === ListingType.PENDING.type) { + filters.querySelector(`.listing-types .filter-toggle input[name="${ListingType.FOR_RENT.type}"]`).closest('.filter-toggle').classList.add('disabled'); + } + }); + + const sort = `${search.sortBy}_${search.sortDirection}`; + filters.querySelector('.sort-options ul li.selected').classList.remove('selected'); + filters.querySelector(`.sort-options select option[value="${sort}"]`).selected = true; + filters.querySelector(`.sort-options ul li[data-value="${sort}"]`).classList.add('selected'); + filters.querySelector('.selected span').textContent = filters.querySelector('.sort-options ul li.selected').textContent; +} + +/** + * Perform the search + * @param {Search} search the search to perform + * @param {boolean} redraw if the map should be updated + * @return {Promise} + */ +async function doSearch(search, redraw = true) { + searchController?.abort(); + searchController = new AbortController(); + window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(search)); + search.franchiseeCode = getMetadata('office-id'); + const contentWrapper = document.querySelector('.property-search-results.block .search-results-content'); + contentWrapper.classList.add('loading'); + contentWrapper.scrollTo({ top: 0, behavior: 'smooth' }); + const parent = document.querySelector('.property-search-results.block .search-results-wrapper'); + return new Promise(() => { + const controller = searchController; + propertySearch(search).then((results) => { + if (!controller.signal.aborted) { + displayList(parent, results); + contentWrapper.querySelector('.search-results-disclaimer-wrapper').replaceChildren( + domEl('hr', { role: 'presentation', 'aria-hidden': true, tabindex: -1 }), + div({ class: 'search-results-disclaimer' }, ...sanitizeDisclaimer(results.disclaimer)), + ); + contentWrapper.classList.remove('loading'); + if (redraw) displayMap(results); + } + }); + }); +} + +export default async function decorate(block) { + const config = readBlockConfig(block); + + const view = BREAKPOINTS.medium.matches ? 'map-view' : 'list-view'; + block.classList.add(view); + /* @formatter:off */ + const filters = div({ class: 'property-search-filters' }, + div({ class: 'listing-types' }, + div({ class: 'filter-toggle' }, + input({ + name: 'FOR_SALE', + hidden: 'hidden', + type: 'checkbox', + 'aria-label': 'Hidden Checkbox', + checked: 'checked', + value: `${ListingType.FOR_SALE.type}`, + }), + div({ class: 'checkbox checked' }), + label({ role: 'presentation' }, ListingType.FOR_SALE.label), + ), + div({ class: 'filter-toggle' }, + input({ + name: 'FOR_RENT', + hidden: 'hidden', + type: 'checkbox', + 'aria-label': 'Hidden Checkbox', + value: `${ListingType.FOR_RENT.type}`, + }), + div({ class: 'checkbox' }), + label({ role: 'presentation' }, ListingType.FOR_RENT.label), + ), + div({ class: 'filter-toggle' }, + input({ + name: 'PENDING', + hidden: 'hidden', + type: 'checkbox', + 'aria-label': 'Hidden Checkbox', + value: `${ListingType.PENDING.type}`, + }), + div({ class: 'checkbox' }), + label({ role: 'presentation' }, ListingType.PENDING.label), + ), + div({ class: 'filter-toggle' }, + input({ + name: 'RECENTLY_SOLD', + hidden: 'hidden', + type: 'checkbox', + 'aria-label': 'Hidden Checkbox', + value: `${ListingType.RECENTLY_SOLD.type}`, + }), + div({ class: 'checkbox' }), + label({ role: 'presentation' }, 'Sold'), + ), + ), + div({ class: 'sort-options' }, + label({ role: 'presentation' }, 'Sort by'), + div({ class: 'select-wrapper' }, + select({ name: 'sort', 'aria-label': 'Distance' }, + // eslint-disable-next-line object-curly-newline + option({ value: 'DISTANCE_ASC', 'data-sort-by': 'DISTANCE', 'data-sort-direction': 'ASC', selected: 'selected' }, 'Distance'), + option({ value: 'PRICE_DESC', 'data-sort-by': 'PRICE', 'data-sort-direction': 'DESC' }, 'Price (Hi-Lo)'), + option({ value: 'PRICE_ASC', 'data-sort-by': 'PRICE', 'data-sort-direction': 'ASC' }, 'Price (Lo-Hi)'), + option({ value: 'DATE_DESC', 'data-sort-by': 'DATE', 'data-sort-direction': 'DESC' }, 'Date (New-Old)'), + option({ value: 'DATE_ASC', 'data-sort-by': 'DATE', 'data-sort-direction': 'ASC' }, 'Date (Old-New)'), + ), + // eslint-disable-next-line object-curly-newline + div({ class: 'selected', role: 'combobox', 'aria-haspopup': 'listbox', 'aria-label': 'Distance', 'aria-expanded': false, 'aria-controls': 'search-results-sort', tabindex: 0 }, + span('Distance'), + ), + ul({ id: 'search-results-sort', class: 'select-items', role: 'listbox' }, + li({ 'data-value': 'DISTANCE_ASC', role: 'option', class: 'selected' }, 'Distance'), + li({ 'data-value': 'PRICE_DESC', role: 'option' }, 'Price (Hi-Lo)'), + li({ 'data-value': 'PRICE_ASC', role: 'option' }, 'Price (Lo-Hi)'), + li({ 'data-value': 'DATE_DESC', role: 'option' }, 'Date (New-Old)'), + li({ 'data-value': 'DATE_ASC', role: 'option' }, 'Date (Old-New)'), + ), + ), + ), + div({ class: 'desktop-view-options view-options' }, + p({ class: 'button-container' }, + a({ class: 'map-view', role: 'button', rel: 'noopener noreferrer' }, 'Map View'), + a({ class: 'list-view', role: 'button', rel: 'noopener noreferrer' }, 'List View'), + ), + ), + ); + + const map = div({ class: 'search-map-wrapper' }, + div({ class: 'search-map-container satellite' }, + div({ class: 'search-results-map' }, div({ id: 'gmap-canvas' })), + ), + ); + /* @formatter:on */ + + const list = div({ class: 'search-results-wrapper' }); + const disclaimer = div({ class: 'search-results-disclaimer-wrapper' }); + + const content = div({ class: 'search-results-content loading' }, loader, list, disclaimer); + + const buttons = div({ class: 'mobile-view-options view-options' }, + p({ class: 'button-container' }, + a({ target: '_blank', role: 'button', rel: 'noopener noreferrer' }, 'Save'), + a({ class: 'map-view', role: 'button', rel: 'noopener noreferrer' }, 'Map View'), + a({ class: 'list-view', role: 'button', rel: 'noopener noreferrer' }, 'List View'), + ), + ); + + block.replaceChildren(filters, map, content, buttons); + + // Default the search results. + let search; + if (window.location.search === '') { + const data = window.sessionStorage.getItem(STORAGE_KEY); + if (data) { + search = await Search.fromJSON(JSON.parse(data)); + } else { + search = await Search.fromBlockConfig(config); + } + window.history.replaceState(null, '', new URL(`/search?${search.asURLSearchParameters()}`, window.location)); + } else { + search = await Search.fromQueryString(window.location.search); + } + updateFilters(search); + updateForm(search); + observe(block); + + window.addEventListener('popstate', async () => { + const newSearch = await Search.fromQueryString(window.location.search); + updateFilters(newSearch); + updateForm(newSearch); + reinitMap(newSearch); + doSearch(newSearch); + }); + + window.addEventListener(UPDATE_SEARCH_EVENT, async (e) => { + const { search: newSearch, redraw } = e.detail; + updateFilters(newSearch); + window.history.pushState(null, '', new URL(`${SEARCH_URL}?${newSearch.asURLSearchParameters().toString()}`, window.location)); + doSearch(newSearch, redraw); + }); + + window.setTimeout(async () => { + const mod = await import(`${window.hlx.codeBasePath}/blocks/property-search-results/map.js`); + initMap = mod.initMap; + initMap(block, search); + doSearch(search); + }, 3000); +} diff --git a/blocks/property-search-results/results.js b/blocks/property-search-results/results.js new file mode 100644 index 00000000..980aa2b0 --- /dev/null +++ b/blocks/property-search-results/results.js @@ -0,0 +1,102 @@ +import { + a, div, li, option, select, span, ul, +} from '../../scripts/dom-helpers.js'; +import { closeOnBodyClick } from '../shared/search/util.js'; +import Search, { UPDATE_SEARCH_EVENT } from '../../scripts/apis/creg/search/Search.js'; +import { render as renderCards } from '../shared/property/cards.js'; + +/** + * Builds the pagination of results + * @param {Number} total total number of pages + * @param {Number} current current page number + */ +function buildPagination(total, current) { + if (Number.isNaN(total) || Number.isNaN(current)) return div(); + const options = []; + const lis = []; + for (let i = 1; i <= total; i += 1) { + options.push(option({ value: i }, i)); + const config = { 'data-value': i }; + if (i === current) config.class = 'selected'; + lis.push(li(config, i)); + } + const displayLabel = `${current} of ${total}`; + const wrapper = div({ class: 'pagination-wrapper' }, + div({ class: 'select-wrapper' }, + select({ name: 'page', 'aria-label': displayLabel }, ...options), + div({ + class: 'selected', + role: 'button', + 'aria-haspopup': 'listbox', + 'aria-label': displayLabel, + 'aria-expanded': false, + tabindex: 0, + }, span(displayLabel)), + ul({ class: 'select-items', role: 'listbox' }, ...lis), + ), + div({ class: 'link-wrapper' }), + ); + + const prev = a({ + class: 'prev', + 'aria-label': 'Previous Page', + role: 'button', + 'data-value': `${current - 1}`, + }); + prev.innerHTML = ''; + const next = a({ + class: 'next', + 'aria-label': 'Next Page', + role: 'button', + 'data-value': `${current + 1}`, + }); + next.innerHTML = ''; + + if (current === 1) { + prev.classList.add('disabled'); + } else if (current === total) { + next.classList.add('disabled'); + } + + wrapper.querySelector('.link-wrapper').append(prev, next); + + closeOnBodyClick(wrapper); + const selectWrapper = wrapper.querySelector('.select-wrapper'); + selectWrapper.querySelector('.selected').addEventListener('click', (e) => { + e.stopPropagation(); + e.preventDefault(); + const open = selectWrapper.classList.toggle('open'); + e.currentTarget.setAttribute('aria-expanded', open); + }); + + const changePage = async (e) => { + e.preventDefault(); + e.stopPropagation(); + const page = e.currentTarget.getAttribute('data-value'); + const search = await Search.fromQueryString(window.location.search); + search.page = page; + window.dispatchEvent(new CustomEvent(UPDATE_SEARCH_EVENT, { detail: { search, redraw: false } })); + }; + + selectWrapper.querySelectorAll('.select-items li').forEach((opt) => { + opt.addEventListener('click', changePage); + }); + prev.addEventListener('click', changePage); + next.addEventListener('click', changePage); + return wrapper; +} + +/** + * Renders the search results as cards into the specified contianer + * @param parent + * @param results + */ +// eslint-disable-next-line import/prefer-default-export +export function displayResults(parent, results) { + const cards = div({ class: 'search-results-cards property-list-cards' }); + renderCards(cards, results.properties); + const pagination = div({ class: 'search-results-pagination' }, + buildPagination(parseInt(results.pages, 10), parseInt(results.page, 10)), + ); + parent.replaceChildren(cards, pagination); +} diff --git a/blocks/property-listing/cards/cards.css b/blocks/shared/property/cards.css similarity index 96% rename from blocks/property-listing/cards/cards.css rename to blocks/shared/property/cards.css index 3ac54d1f..cca4a1f2 100644 --- a/blocks/property-listing/cards/cards.css +++ b/blocks/shared/property/cards.css @@ -2,7 +2,6 @@ .property-list-cards { display: flex; - height: 400px; overflow-x: auto; scroll-snap-type: x mandatory; scroll-behavior: smooth; @@ -11,9 +10,6 @@ padding-bottom: 10px; } -.property-list-cards::-webkit-scrollbar { - display: none; -} .property-list-cards .listing-tile { display: flex; @@ -55,7 +51,7 @@ .property-list-cards .listing-image-container { position: relative; - height: 100%; + padding-top: 73.5294%; } .property-list-cards .listing-image-container .property-image { @@ -66,6 +62,7 @@ right: 0; bottom: 0; object-fit: cover; + object-position: center; } .property-list-cards .is-sold .listing-image-container .property-image { @@ -148,7 +145,7 @@ justify-content: center; } -.property-list-cards .property-labels .property-label.open-house svg { +.property-list-cards .property-labels .property-label.open-house img { height: 24px; width: 24px; margin-right: 5px; @@ -178,13 +175,13 @@ bottom: 0; } -.property-list-cards .listing-image-container .property-price { - padding-top: 7px; - padding-left: 10px; +.property-list-cards .listing-image-container .property-price p { + display: inline-block;; + margin: 0; + padding: 7px 10px; font-weight: var(--font-weight-bold); font-size: var(--body-font-size-l); line-height: var(--line-height-s); - max-width: 70%; background-color: var(--white); color: var(--body-color); } @@ -265,7 +262,7 @@ left: 0; } -.property-list-cards .property-details .property-buttons .button-property svg { +.property-list-cards .property-details .property-buttons .button-property img { width: 24px; height: 24px; } @@ -334,7 +331,6 @@ @media (min-width: 1200px) { .property-list-cards { display: grid; - height: 820px; grid-template: repeat(2, 1fr) / repeat(4, 1fr); gap: 20px; padding-bottom: 20px; diff --git a/blocks/property-listing/cards/cards.js b/blocks/shared/property/cards.js similarity index 67% rename from blocks/property-listing/cards/cards.js rename to blocks/shared/property/cards.js index d690fc3e..10092676 100644 --- a/blocks/property-listing/cards/cards.js +++ b/blocks/shared/property/cards.js @@ -1,9 +1,6 @@ -import { propertySearch } from '../../../scripts/apis/creg/creg.js'; -import { decorateIcons } from '../../../scripts/aem.js'; - function createImage(listing) { if (listing.SmallMedia?.length > 0) { - return `Property Image`; + return `${listing.StreetName}`; } return '
    no images available
    '; } @@ -41,7 +38,7 @@ export function createCard(listing) { if (listing.PdpPath.includes('LuxuryTheme=true')) { item.classList.add('is-luxury'); } - const applicationType = listing.ApplicationType && listing.ApplicationType === 'For Rent' ? `${listing.ApplicationType}` : ''; + const applicationType = listing.ListingType && listing.ListingType === 'For Rent' ? `${listing.ListingType}` : ''; if (listing.ClosedDate !== '01/01/0001') { item.classList.add('is-sold'); @@ -49,7 +46,7 @@ export function createCard(listing) { } item.innerHTML = ` - +
    ${createImage(listing)} @@ -70,7 +67,7 @@ export function createCard(listing) { ${listing.mlsStatus}
    - ${listing.ListPriceUS} +

    ${listing.ListPriceUS}

    @@ -79,7 +76,7 @@ export function createCard(listing) {
    Closed: ${listing.ClosedDate}
    -
    +
    ${listing.StreetName}
    ${listing.City}, ${listing.StateOrProvince} ${listing.PostalCode} @@ -89,13 +86,21 @@ export function createCard(listing) {
    @@ -117,21 +122,13 @@ export function createCard(listing) { /** * Render the results of the provided search into the specified parent element. * - * @param {SearchParameters} searchParams * @param {HTMLElement} parent - * @return {Promise} + * @param {Object[]} properties results from CREG */ -export async function render(searchParams, parent) { - const list = document.createElement('div'); - list.classList.add('property-list-cards'); - parent.append(list); - - propertySearch(searchParams).then((results) => { - if (results?.properties) { - results.properties.forEach((listing) => { - list.append(createCard(listing)); - }); - decorateIcons(parent); - } +export function render(parent, properties = []) { + const cards = []; + properties.forEach((listing) => { + cards.push(createCard(listing)); }); + parent.replaceChildren(...cards); } diff --git a/blocks/property-listing/cards/luxury-collection-template.css b/blocks/shared/property/luxury-collection-template.css similarity index 71% rename from blocks/property-listing/cards/luxury-collection-template.css rename to blocks/shared/property/luxury-collection-template.css index 3d516487..0c7326cc 100644 --- a/blocks/property-listing/cards/luxury-collection-template.css +++ b/blocks/shared/property/luxury-collection-template.css @@ -1,11 +1,12 @@ .luxury-collection .property-list-cards .listing-image-container .property-image { - z-index: 1; + z-index: 1; } + .luxury-collection .property-list-cards .property-labels { - background: initial; + background: initial; } .luxury-collection .property-list-cards .property-price { - background: initial; - z-index: 2; -} \ No newline at end of file + background: initial; + z-index: 2; +} diff --git a/blocks/shared/search-countries/search-countries.js b/blocks/shared/search-countries/search-countries.js index 27fef4e5..ed7640d2 100644 --- a/blocks/shared/search-countries/search-countries.js +++ b/blocks/shared/search-countries/search-countries.js @@ -21,7 +21,7 @@ function addListeners(wrapper, cbs) { wrapper.querySelector('.select-items .item.selected')?.classList.remove('selected'); e.currentTarget.classList.add('selected'); - wrapper.querySelector('select option[selected="selected"]')?.removeAttribute('selected'); + wrapper.querySelectorAll('select option').forEach((o) => { o.selected = false; }); wrapper.querySelector(`select option[value="${selected}"]`).setAttribute('selected', 'selected'); wrapper.classList.toggle('open'); if (cbs) { diff --git a/blocks/shared/search/suggestion.js b/blocks/shared/search/suggestion.js new file mode 100644 index 00000000..e613be74 --- /dev/null +++ b/blocks/shared/search/suggestion.js @@ -0,0 +1,103 @@ +import { + getSelected as getSelectedCountry, +} from '../search-countries/search-countries.js'; +import { + abort as abortSuggestions, + get as getSuggestions, +} from '../../../scripts/apis/creg/suggestion.js'; + +const MORE_INPUT_NEEDED = 'Please enter at least 3 characters.'; +const NO_SUGGESTIONS = 'No suggestions found. Please modify your search.'; +const SEARCHING_SUGGESTIONS = 'Looking up suggestions...'; + +const updateSuggestions = (suggestions, target) => { + // Keep the first item - required character entry count. + const first = target.querySelector(':scope li'); + target.replaceChildren(first, ...suggestions); +}; + +const buildSuggestions = (suggestions) => { + const lists = []; + suggestions.forEach((category) => { + const list = document.createElement('li'); + list.classList.add('list-title'); + list.textContent = category.displayText; + lists.push(list); + const ul = document.createElement('ul'); + list.append(ul); + category.results.forEach((result) => { + const li = document.createElement('li'); + li.setAttribute('category', category.searchType); + li.setAttribute('display', result.displayText.trim()); + li.setAttribute('query', result.QueryString); + li.setAttribute('type', new URLSearchParams(result.QueryString).get('SearchType')); + li.textContent = result.SearchParameter; + ul.append(li); + }); + }); + + return lists; +}; + +/** + * Handles the input changed event for the text field. Will add suggestions based on user input. + * + * @param {Event} e the change event + * @param {HTMLElement} target the container in which to add suggestions + */ +const inputChanged = (e, target) => { + const { currentTarget } = e; + const { value } = currentTarget; + const searchBar = currentTarget.closest('.search-bar'); + if (value.length > 0) { + searchBar.classList.add('show-suggestions'); + } else { + searchBar.classList.remove('show-suggestions'); + searchBar.querySelector('input[name="query"]').value = ''; + searchBar.querySelector('input[name="type"]').value = ''; + } + + if (value.length <= 2) { + abortSuggestions(); + target.querySelector(':scope > li:first-of-type').textContent = MORE_INPUT_NEEDED; + updateSuggestions([], target); + } else { + target.querySelector(':scope > li:first-of-type').textContent = SEARCHING_SUGGESTIONS; + getSuggestions(value, getSelectedCountry(currentTarget.closest('form'))) + .then((suggestions) => { + if (!suggestions) { + // Undefined suggestions means it was aborted, more input coming. + updateSuggestions([], target); + return; + } + if (suggestions.length) { + updateSuggestions(buildSuggestions(suggestions), target); + } else { + target.querySelector(':scope > li:first-of-type').textContent = NO_SUGGESTIONS; + } + }); + } +}; + +const suggestionSelected = (e, form) => { + const query = e.target.getAttribute('query'); + const keyword = e.target.getAttribute('display'); + const type = e.target.getAttribute('type'); + if (!query) { + return; + } + form.querySelector('input[name="keyword"]').value = keyword; + form.querySelector('input[name="query"]').value = query; + form.querySelector('input[name="type"]').value = type; + form.querySelector('.search-bar').classList.remove('show-suggestions'); +}; + +export default function addEventListeners(form) { + const suggestionsTarget = form.querySelector('.suggester-input .suggester-results'); + form.querySelector('.suggester-input input').addEventListener('input', (e) => { + inputChanged(e, suggestionsTarget); + }); + suggestionsTarget.addEventListener('click', (e) => { + suggestionSelected(e, form); + }); +} diff --git a/blocks/shared/search/util.js b/blocks/shared/search/util.js new file mode 100644 index 00000000..832212ac --- /dev/null +++ b/blocks/shared/search/util.js @@ -0,0 +1,168 @@ +import { getMetadata } from '../../../scripts/aem.js'; + +export const BED_BATHS = [ + { value: 1, label: '1+' }, + { value: 2, label: '2+' }, + { value: 3, label: '3+' }, + { value: 4, label: '4+' }, + { value: 5, label: '5+' }, +]; + +export function getPlaceholder() { + const country = getMetadata('country') || 'US'; + return country === 'US' ? 'Enter City, Address, Zip/Postal Code, Neighborhood, School or MLS#' : 'Enter City'; +} + +let bodyCloseListener; +/** + * Helper function to close an expanded item when click events occur elsewhere on the page. + * @param {HTMLElement} root context for closing elements + */ +export function closeOnBodyClick(root) { + if (bodyCloseListener) { + document.body.removeEventListener('click', bodyCloseListener); + } + bodyCloseListener = (e) => { + // Don't close if we clicked somewhere inside of the context. + if (root.contains(e.target)) { + return; + } + root.classList.remove('open'); + root.querySelectorAll('.open').forEach((open) => open.classList.remove('open')); + root.querySelectorAll('[aria-expanded="true"]').forEach((expanded) => expanded.setAttribute('aria-expanded', 'false')); + document.body.removeEventListener('click', bodyCloseListener); + bodyCloseListener = undefined; + }; + document.body.addEventListener('click', bodyCloseListener); +} + +/** + * Creates a Select dropdown for filtering search. + * @param {String} name name of select + * @param {String} placeholder label + * @param {Array[Object]} options max number of options + * @param {String} options.value value for option entry + * @param {String} options.label label for option entry + * @returns {HTMLDivElement} + */ +export function buildFilterSelect(name, placeholder, options) { + const wrapper = document.createElement('div'); + wrapper.classList.add('select-wrapper', name); + wrapper.innerHTML = ` + + +
      +
    • Any ${placeholder}
    • +
    + `; + + const select = wrapper.querySelector('select'); + const ul = wrapper.querySelector('ul'); + options.forEach((option) => { + const ele = document.createElement('option'); + const li = document.createElement('li'); + li.setAttribute('role', 'option'); + li.setAttribute('data-value', option.value); + ele.value = option.value; + // eslint-disable-next-line no-multi-assign + ele.textContent = li.textContent = `${option.label} ${placeholder}`; + select.append(ele); + ul.append(li); + }); + return wrapper; +} + +export function filterItemClicked(e) { + e.preventDefault(); + e.stopPropagation(); + const value = e.currentTarget.getAttribute('data-value'); + const wrapper = e.currentTarget.closest('.select-wrapper'); + wrapper.querySelector('.selected span').textContent = e.currentTarget.textContent; + wrapper.querySelector('ul li.selected')?.classList.toggle('selected'); + e.currentTarget.classList.add('selected'); + wrapper.querySelectorAll('select option').forEach((o) => { o.selected = false; }); + if (!value) { + wrapper.querySelector('select option[value=""]').selected = true; + } else { + wrapper.querySelector(`select option[value="${value}"]`).selected = true; + } + wrapper.classList.remove('open'); + wrapper.querySelector('[aria-expanded="true"]')?.setAttribute('aria-expanded', 'false'); +} + +/** + * Creates a from/to range input field for the search filter + * @param name parameter name + * @param placeholder label + */ +export function buildDataListRange(name, placeholder) { + const wrapper = document.createElement('div'); + wrapper.classList.add('range-wrapper', name); + wrapper.innerHTML = ` + +
    +
    + + +
    + to +
    + + +
    +
    + `; + return wrapper; +} + +/** + * Creates a from/to range input field with selections for the options + * @param name parameter name + * @param placeholder placeholder + * @param boundaries boundaries for ranges. + */ +export function buildSelectRange(name, placeholder, boundaries) { + const wrapper = document.createElement('div'); + wrapper.classList.add('range-wrapper', name); + wrapper.innerHTML = ` + +
    +
    + + +
      +
    • No Min
    • +
    +
    + to +
    + + +
      +
    • No Max
    • +
    +
    +
    + `; + + wrapper.querySelectorAll(`#min-${name}, #max-${name}`).forEach((item) => { + boundaries.forEach((b) => { + const opt = document.createElement('option'); + opt.value = b.value; + opt.textContent = b.label; + item.querySelector('select').append(opt); + const li = document.createElement('li'); + li.setAttribute('data-value', b.value); + li.setAttribute('role', 'option'); + li.textContent = b.label; + item.querySelector('ul').append(li); + }); + }); + return wrapper; +} diff --git a/icons/checkmark.svg b/icons/checkmark.svg index b888e84f..db7e1ffc 100644 --- a/icons/checkmark.svg +++ b/icons/checkmark.svg @@ -1 +1,3 @@ - \ No newline at end of file + + + diff --git a/icons/close-x-white.svg b/icons/close-x-white.svg new file mode 100644 index 00000000..bfdb1439 --- /dev/null +++ b/icons/close-x-white.svg @@ -0,0 +1,11 @@ + diff --git a/icons/close-x.svg b/icons/close-x.svg new file mode 100644 index 00000000..f3d89559 --- /dev/null +++ b/icons/close-x.svg @@ -0,0 +1,11 @@ + diff --git a/icons/filter-white.svg b/icons/filter-white.svg new file mode 100644 index 00000000..52cc5962 --- /dev/null +++ b/icons/filter-white.svg @@ -0,0 +1,18 @@ + diff --git a/icons/globe.png b/icons/globe.png new file mode 100644 index 00000000..7717876f Binary files /dev/null and b/icons/globe.png differ diff --git a/icons/heartempty.svg b/icons/heartempty.svg index 5c92e930..623bab0d 100644 --- a/icons/heartempty.svg +++ b/icons/heartempty.svg @@ -5,5 +5,5 @@ tabindex="-1" viewBox="0 0 24 21"> + fill="#AAA"/> diff --git a/icons/heartemptydark.svg b/icons/heartemptydark.svg index 9efbc173..e94647b4 100644 --- a/icons/heartemptydark.svg +++ b/icons/heartemptydark.svg @@ -1,9 +1,9 @@ - diff --git a/icons/heartfull.svg b/icons/heartfull.svg new file mode 100644 index 00000000..01c8256f --- /dev/null +++ b/icons/heartfull.svg @@ -0,0 +1,9 @@ + diff --git a/icons/maps/loader_opt.mp4 b/icons/maps/loader_opt.mp4 deleted file mode 100644 index 7e9da187..00000000 Binary files a/icons/maps/loader_opt.mp4 and /dev/null differ diff --git a/icons/maps/loader_opt.webm b/icons/maps/loader_opt.webm deleted file mode 100644 index 90ba4065..00000000 Binary files a/icons/maps/loader_opt.webm and /dev/null differ diff --git a/icons/maps/map-reveal-marker-standard.png b/icons/maps/map-reveal-marker-standard.png deleted file mode 100644 index cfb600ea..00000000 Binary files a/icons/maps/map-reveal-marker-standard.png and /dev/null differ diff --git a/icons/pencil.svg b/icons/pencil.svg new file mode 100644 index 00000000..c1fb1664 --- /dev/null +++ b/icons/pencil.svg @@ -0,0 +1 @@ + Combined Shape Created with Sketch. \ No newline at end of file diff --git a/icons/search.svg b/icons/search.svg new file mode 100644 index 00000000..aa6794ae --- /dev/null +++ b/icons/search.svg @@ -0,0 +1,11 @@ + diff --git a/package-lock.json b/package-lock.json index 2b02f397..77f02222 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "eslint": "8.35.0", "eslint-config-airbnb-base": "15.0.0", "eslint-plugin-import": "2.27.5", + "mocha": "^10.2.0", "sinon": "15.0.1", "stylelint": "15.2.0", "stylelint-config-standard": "30.0.1" @@ -1425,6 +1426,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -1703,6 +1713,12 @@ "node": ">=8" } }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, "node_modules/browserslist": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", @@ -2087,6 +2103,67 @@ "node": ">=8" } }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/cliui/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", @@ -3304,6 +3381,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, "node_modules/flat-cache": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.1.tgz", @@ -3413,6 +3499,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -3702,6 +3797,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -4264,6 +4368,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-weakref": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", @@ -4680,6 +4796,92 @@ "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", "dev": true }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/log-update": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", @@ -4959,6 +5161,171 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, + "node_modules/mocha": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", + "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==", + "dev": true, + "dependencies": { + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.4", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "5.0.1", + "ms": "2.1.3", + "nanoid": "3.3.3", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "workerpool": "6.2.1", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "node_modules/mocha/node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/mocha/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mocha/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/minimatch/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mocha/node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "dev": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mocha/node_modules/yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -5744,6 +6111,15 @@ "node": ">=8" } }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/raw-body": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", @@ -5990,6 +6366,15 @@ "url": "https://github.com/sponsors/mysticatea" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6217,6 +6602,15 @@ "semver": "bin/semver.js" } }, + "node_modules/serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/set-function-length": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", @@ -7296,6 +7690,12 @@ "node": ">=8" } }, + "node_modules/workerpool": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", + "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", + "dev": true + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -7395,12 +7795,39 @@ } } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/yargs-parser": { "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", @@ -7410,6 +7837,42 @@ "node": ">=10" } }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser/node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs-unparser/node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/package.json b/package.json index da0ff83e..e452355c 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,10 @@ "name": "hsf-commonmoves", "private": true, "version": "1.0.0", + "type": "module", "description": "Starter project for Adobe Helix", "scripts": { + "test": "mocha", "lint:js": "eslint .", "lint:css": "stylelint blocks/**/*.css styles/*.css", "lint": "npm run lint:js && npm run lint:css" @@ -21,13 +23,14 @@ "devDependencies": { "@babel/core": "7.21.0", "@babel/eslint-parser": "7.19.1", + "@esm-bundle/chai": "4.3.4-fix.0", + "@web/test-runner": "0.15.1", + "@web/test-runner-commands": "0.6.5", "chai": "4.3.7", "eslint": "8.35.0", "eslint-config-airbnb-base": "15.0.0", "eslint-plugin-import": "2.27.5", - "@esm-bundle/chai": "4.3.4-fix.0", - "@web/test-runner": "0.15.1", - "@web/test-runner-commands": "0.6.5", + "mocha": "^10.2.0", "sinon": "15.0.1", "stylelint": "15.2.0", "stylelint-config-standard": "30.0.1" diff --git a/scripts/apis/creg/ApplicationType.js b/scripts/apis/creg/ApplicationType.js deleted file mode 100644 index 950daa53..00000000 --- a/scripts/apis/creg/ApplicationType.js +++ /dev/null @@ -1,25 +0,0 @@ -export default class ApplicationType { - constructor(type, label) { - this.type = type; - this.label = label; - } -} - -ApplicationType.FOR_SALE = new ApplicationType('FOR_SALE', 'For Sale'); -ApplicationType.FOR_RENT = new ApplicationType('FOR_RENT', 'For Rent'); -ApplicationType.PENDING = new ApplicationType('PENDING', 'Pending'); -ApplicationType.RECENTLY_SOLD = new ApplicationType('RECENTLY_SOLD', 'Recently Sold'); - -/** -* Returns the ApplicationType for the specified string. -* -* @param {string} type -* @returns {ApplicationType} the matching type. -*/ -export function applicationTypeFor(type) { - const [found] = Object.getOwnPropertyNames(ApplicationType) - .filter((t) => t.toLowerCase() === type.toLowerCase()) - .map((t) => ApplicationType[t]); - - return found; -} diff --git a/scripts/apis/creg/OpenHouses.js b/scripts/apis/creg/OpenHouses.js deleted file mode 100644 index 89e24046..00000000 --- a/scripts/apis/creg/OpenHouses.js +++ /dev/null @@ -1,9 +0,0 @@ -export default class OpenHouses { - constructor(value, label) { - this.value = value; - this.label = label; - } -} - -OpenHouses.ONLY_WEEKEND = new OpenHouses(7, 'This Weekend'); -OpenHouses.ANYTIME = new OpenHouses(365, 'Anytime'); diff --git a/scripts/apis/creg/PropertyType.js b/scripts/apis/creg/PropertyType.js deleted file mode 100644 index 7cad72b7..00000000 --- a/scripts/apis/creg/PropertyType.js +++ /dev/null @@ -1,13 +0,0 @@ -export default class PropertyType { - constructor(id, label) { - this.ID = id; - this.Label = label; - } -} - -PropertyType.CONDO_TOWNHOUSE = new PropertyType(1, 'Condo/Townhouse'); -PropertyType.SINGLE_FAMILY = new PropertyType(2, 'Single Family'); -PropertyType.COMMERCIAL = new PropertyType(3, 'Commercial'); -PropertyType.MULTI_FAMILY = new PropertyType(4, 'Multi Family'); -PropertyType.LAND = new PropertyType(5, 'Lot/Land'); -PropertyType.FARM = new PropertyType(6, 'Farm/Ranch'); diff --git a/scripts/apis/creg/SearchParameters.js b/scripts/apis/creg/SearchParameters.js deleted file mode 100644 index 2e964c0c..00000000 --- a/scripts/apis/creg/SearchParameters.js +++ /dev/null @@ -1,155 +0,0 @@ -import SearchType from './SearchType.js'; -import PropertyType from './PropertyType.js'; -import ApplicationType from './ApplicationType.js'; - -const parseQuery = (queryString) => { - const parsed = {}; - queryString.split('&').map((kvp) => kvp.split('=')).forEach((kv) => { - parsed[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1]); - }); - return parsed; -}; - -export const SortDirections = Object.freeze({ - ASC: 'ASCENDING', - DESC: 'DESCENDING', -}); - -export const SortOptions = Object.freeze({ - DATE: 'DATE', - PRICE: 'PRICE', - DISTANCE: 'DISTANCE', -}); - -export default class SearchParameters { - static DEFAULT_PAGE_SIZE = 32; - - ListingStatus; // TODO: Find out what this means - - MinPrice; - - NewListing = false; - - OpenHouses; - - Page = 1; - - PageSize = 32; - - SearchInput = ''; - - SearchType = ''; - - #ApplicationType = ApplicationType.FOR_SALE.type; - - #PropertyType = [PropertyType.CONDO_TOWNHOUSE, PropertyType.SINGLE_FAMILY].join(','); - - #franchiseeCode; - - #isFranchisePage = false; - - #params = ''; - - #sort = 'PRICE_DESCENDING'; - - /** - * Create a new instance of the SearchParameters. - * - * @param {SearchType} type the type of search to perform - * @param {string} params the formatted params using the 'paramFormatterBuilder' from the type - */ - constructor(type = SearchType.Empty, params = '') { - this.SearchType = type.type; - this.#params = params; - } - - /** - * Set the types of property status type to include in search. - * - * @param {ApplicationType[]} types - */ - set applicationTypes(types) { - this.#ApplicationType = types.map((t) => t.type).join(','); - } - - /** - * Set the franchisee ID for context search. - * - * @param id - */ - set franchisee(id) { - if (id) { - this.#franchiseeCode = id.toUpperCase(); - this.#isFranchisePage = true; - } else { - this.#franchiseeCode = undefined; - this.#isFranchisePage = false; - } - } - - /** - * Set the property types to search against. - * - * @param {PropertyType[]} types - */ - set propertyTypes(types) { - this.#PropertyType = types.map((t) => t.ID).join(','); - } - - /** - * Set the sort type - * - * @param {('DATE'|'PRICE')} sort property on which to sort - */ - set sortBy(sort) { - const formatted = sort.toUpperCase(); - if (SortOptions[formatted]) { - this.#sort = `${SortOptions[formatted]}_${this.#sort.split('_')[1]}`; - } - } - - /** - * Set the sort direction - * - * @param {('ASC'|'DESC')} direction the direction of the sort - */ - set sortDirection(direction) { - const formatted = direction.toUpperCase(); - if (SortDirections[formatted]) { - this.#sort = `${this.#sort.split('_')[0]}_${SortDirections[formatted]}`; - } - } - - /** - * Populates this search parameter from the provided query string. - * - * @param {String} queryString the query string to parse - */ - populate(queryString) { - const query = parseQuery(queryString); - Object.keys(this).forEach((p) => { - if (query[p]) { - this[p] = query[p]; - } - }); - } - - /** - * Converts this Search Parameter object into its URL query parameter equivalent - * - * @return {String} URL encoded representation of this - */ - asQueryString() { - let query = Object.keys(this).filter((k) => this[k]).map((k) => `${k}=${encodeURIComponent(this[k])}`).join('&'); - query += `&PropertyType=${this.#PropertyType}&ApplicationType=${this.#ApplicationType}`; - query += `&Sort=${this.#sort}&isFranchisePage=${this.#isFranchisePage}`; - if (this.#params) { - query += `&${this.#params}`; - } - if (this.#franchiseeCode) { - query += `&franchiseeCode=${this.#franchiseeCode}`; - } - - return query; - } -} diff --git a/scripts/apis/creg/SearchType.js b/scripts/apis/creg/SearchType.js deleted file mode 100644 index ae22b6b9..00000000 --- a/scripts/apis/creg/SearchType.js +++ /dev/null @@ -1,55 +0,0 @@ -const defaultParameterBuilder = (v) => `SearchParameter=${encodeURIComponent(v)}`; - -const mapParameterBuilder = (minLat, maxLat, minLon, maxLon) => { - const obj = { - type: 'FeatureCollection', - features: [{ - type: 'Feature', - geometry: { - type: 'Polygon', - coordinates: [[ - [minLon, minLat], // Bottom left - [minLon, maxLat], // Top left - [maxLon, maxLat], // Top right - [maxLon, minLat], // Bottom right - [minLon, minLat], // Close the box - ]], - }, - }], - }; - return `SearchParameter=${encodeURIComponent(JSON.stringify(obj))}`; -}; - -const radiusParameterBuilder = (lat, lon, radius) => `Latitude=${lat}&Longitude=${lon}&Distance=${radius}`; - -export default class SearchType { - constructor(type, parameterBuilder) { - this.type = type; - this.paramBuilder = parameterBuilder; - } -} - -SearchType.Address = new SearchType('Address', defaultParameterBuilder); -SearchType.City = new SearchType('City', defaultParameterBuilder); -SearchType.Community = new SearchType('Community', mapParameterBuilder); -SearchType.Empty = new SearchType('Empty', () => ''); -SearchType.Map = new SearchType('Map', mapParameterBuilder); -SearchType.Neighborhood = new SearchType('Neighborhood', defaultParameterBuilder); -SearchType.Radius = new SearchType('Radius', radiusParameterBuilder); -SearchType.School = new SearchType('School', defaultParameterBuilder); -SearchType.SchoolDistrict = new SearchType('School District', defaultParameterBuilder); -SearchType.ZipCode = new SearchType('ZipCode', defaultParameterBuilder); -SearchType.listingId = new SearchType('listingId', defaultParameterBuilder); - -/** - * Returns the SearchType for the specified string. - * - * @param {string} type - * @returns {SearchType} the matching type. - */ -export function searchTypeFor(type) { - const [found] = Object.getOwnPropertyNames(SearchType) - .filter((t) => t.toLowerCase() === type.toLowerCase()) - .map((t) => SearchType[t]); - return found; -} diff --git a/scripts/apis/creg/creg.js b/scripts/apis/creg/creg.js index 4080f9e4..50a93e5b 100644 --- a/scripts/apis/creg/creg.js +++ b/scripts/apis/creg/creg.js @@ -1,92 +1,69 @@ /* Wrapper for all Creg API endpoints */ -// eslint-disable-next-line no-unused-vars -import SearchParameters from './SearchParameters.js'; +// TODO: Use Sidekick Plugin for this +import { getMetadata } from '../../aem.js'; const urlParams = new URLSearchParams(window.location.search); export const DOMAIN = urlParams.get('env') === 'stage' ? 'ignite-staging.bhhs.com' : 'www.bhhs.com'; const CREG_API_URL = `https://${DOMAIN}/bin/bhhs`; -let suggestionFetchController; - -const mapSuggestions = (json) => { - const results = []; - const { searchTypes, suggestions } = json; - - if (!suggestions) { - return results; - } - - const keys = Object.keys(suggestions); - keys.forEach((k) => { - if (!suggestions[k.toLowerCase()]) { - suggestions[k.toLowerCase()] = suggestions[k]; - } - }); - searchTypes.forEach((type) => { - const name = type.searchType.replaceAll(/\s+/g, '').toLowerCase(); - if (suggestions[name] && suggestions[name].length) { - results.push({ - ...type, - results: suggestions[name], - }); - } - }); - - return results; -}; +/** + * @typedef {Object} SearchResults + * @property {Array} properties + * @property {String} disclaimer + * @property {Array} clusters + * @property {String} pages + * @property {String} count + */ /** - * Get suggestions for users based on their input and optional country. - * - * @param {String} keyword the partial for suggestion search - * @param {String} [country=undefined] optional country for narrowing search + * Perform a search and return only the properties. * - * @return {Promise|undefined} - * Any available suggestions, or undefined if the search was aborted. + * @param {Search} search the Search object instance + * @return {Promise} resolving the properties fetched */ -export async function getSuggestions(keyword, country = undefined) { - suggestionFetchController?.abort(); - suggestionFetchController = new AbortController(); - - const { signal } = suggestionFetchController; - - let endpoint = `${CREG_API_URL}/cregSearchSuggesterServlet?Keyword=${keyword}&_=${Date.now()}`; - if (country) { - endpoint += `&Country=${country}`; - } - - return fetch(endpoint, { signal }) - .then((resp) => { - if (resp.ok) { - return resp.json().then(mapSuggestions); - } - // eslint-disable-next-line no-console - console.log('Unable to fetch suggestions.'); - return []; - }).catch((err) => { - if (err.name === 'AbortError') { - return undefined; - } - throw err; +export async function propertySearch(search) { + return new Promise((resolve) => { + const worker = new Worker(`${window.hlx.codeBasePath}/scripts/apis/creg/workers/properties.js`, { type: 'module' }); + worker.onmessage = (e) => resolve(e.data); + worker.postMessage({ + api: CREG_API_URL, + search, }); + }); } -export function abortSuggestions() { - suggestionFetchController?.abort(); +/** + * Perform a search, returning the metadata. + * + * @param {Search} search the Search object instance + * @return {Promise} resolving the properties fetched + */ +export async function metadataSearch(search) { + return new Promise((resolve) => { + const worker = new Worker(`${window.hlx.codeBasePath}/scripts/apis/creg/workers/metadata.js`, { type: 'module' }); + worker.onmessage = (e) => resolve(e.data); + worker.postMessage({ + api: CREG_API_URL, + search, + }); + }); } /** - * Perform a search. + * Gets the details for the specified listings. * - * @param {SearchParameters} params the parameters + * @param {string[]} listingIds list of listing ids */ -export function propertySearch(params) { +export async function getDetails(...listingIds) { return new Promise((resolve) => { - const queryParams = params.asQueryString(); - const worker = new Worker(`${window.hlx.codeBasePath}/scripts/apis/creg/workers/propertySearch.js`); - const url = `${CREG_API_URL}/CregPropertySearchServlet?${queryParams}&_=${Date.now()}`; + const officeId = getMetadata('office-id'); + const worker = new Worker(`${window.hlx.codeBasePath}/scripts/apis/creg/workers/listing.js`, { type: 'module' }); worker.onmessage = (e) => resolve(e.data); - worker.postMessage({ url }); + worker.postMessage({ + api: CREG_API_URL, + ids: listingIds, + officeId, + }); }); } diff --git a/scripts/apis/creg/search/Search.js b/scripts/apis/creg/search/Search.js new file mode 100644 index 00000000..ca11f0f0 --- /dev/null +++ b/scripts/apis/creg/search/Search.js @@ -0,0 +1,391 @@ +import ListingType from './types/ListingType.js'; +import PropertyType from './types/PropertyType.js'; +import OpenHouses from './types/OpenHouses.js'; + +export const UPDATE_SEARCH_EVENT = 'UpdateSearch'; + +export const STORAGE_KEY = 'CommonMovesSearchParams'; +export const SEARCH_URL = '/search'; + +export default class Search { + input; + + type = 'Empty'; + + minPrice; + + maxPrice; + + minBedrooms; + + minBathrooms; + + minSqft; + + maxSqft; + + keywords = []; + + matchAnyKeyword = false; + + minYear; + + maxYear; + + isNew = false; + + priceChange = false; + + luxury = false; + + bhhsOnly = false; + + page = '1'; + + pageSize = '36'; + + franchiseeCode; + + // Private + #openHouses; + + #listingTypes = [ListingType.FOR_SALE]; + + #propertyTypes = [PropertyType.CONDO_TOWNHOUSE, PropertyType.SINGLE_FAMILY]; + + #sortBy = 'PRICE'; + + #sortDirection = 'DESC'; + + constructor() { + Object.defineProperties(this, { + openHouses: { + enumerable: true, + set: (value) => { + if (typeof value === 'object' && OpenHouses[value.name]) { + this.#openHouses = OpenHouses[value.name]; + } else { + this.#openHouses = OpenHouses[value] ? OpenHouses[value] : undefined; + } + }, + get: () => this.#openHouses, + }, + + listingTypes: { + enumerable: true, + set: (types) => { + this.#listingTypes = []; + // eslint-disable-next-line no-param-reassign + types = !Array.isArray(types) ? [types] : types; + types.forEach((type) => { + this.addListingType(type); + }); + }, + get: () => [...this.#listingTypes], + }, + + propertyTypes: { + enumerable: true, + set: (types) => { + this.#propertyTypes = []; + // eslint-disable-next-line no-param-reassign + types = !Array.isArray(types) ? [types] : types; + types.forEach((type) => { + this.addPropertyType(type); + }); + }, + get: () => [...this.#propertyTypes], + }, + + sortBy: { + enumerable: true, + set: (value) => { + if (['DATE', 'PRICE', 'DISTANCE'].includes(value.toUpperCase())) { + this.#sortBy = value.toUpperCase(); + } + }, + get: () => this.#sortBy, + }, + + sortDirection: { + enumerable: true, + set: (value) => { + ['ASC', 'DESC'].forEach((d) => { + if (value.toUpperCase().indexOf(d) > -1) { + this.#sortDirection = d; + } + }); + }, + get: () => this.#sortDirection, + }, + }); + } + + /** + * Adds a property type to the current list. + * + * @param {ListingType|String} type + */ + addListingType(type) { + let t; + if (typeof type === 'object' && ListingType[type.type]) { + t = ListingType[type.type]; + } else if (typeof type === 'string' && ListingType[type]) { + t = ListingType[type]; + } + if (t && !this.#listingTypes.includes(t)) this.#listingTypes.push(t); + } + + removeListingType(type) { + let t; + if (typeof type === 'object' && ListingType[type.type]) { + t = ListingType[type.type]; + } else if (typeof type === 'string' && ListingType[type]) { + t = ListingType[type]; + } + if (t && this.#listingTypes.includes(t)) { + const idx = this.#listingTypes.indexOf(t); + this.#listingTypes.splice(idx, 1); + } + } + + /** + * Adds a property type to the current list. + * + * @param {PropertyType|String} type + */ + addPropertyType(type) { + let t; + if (typeof type === 'object') { + if (type.name) { + t = PropertyType[type.name]; + } else if (type.id) { + t = PropertyType.fromId(type.id); + } + } else if (typeof type === 'string') { + t = PropertyType[type]; + } else if (typeof type === 'number') { + t = PropertyType.fromId(type); + } + if (t && !this.#propertyTypes.includes(t)) this.#propertyTypes.push(t); + } + + /** + * Removes a property type to the current list. + * + * @param {PropertyType|String} type + */ + removePropertyType(type) { + let t; + if (typeof type === 'object') { + if (type.name) { + t = PropertyType[type.name]; + } else if (type.id) { + t = PropertyType.fromId(type.id); + } + } else if (typeof type === 'string') { + t = PropertyType[type]; + } else if (typeof type === 'number') { + t = PropertyType.fromId(type); + } + if (t && this.#propertyTypes.includes(t)) { + const idx = this.#propertyTypes.indexOf(t); + this.#propertyTypes.splice(idx, 1); + } + } + + /** + * Converts this Search instance into URL Search Parameters for the CREG API. + * @return {URLSearchParams} + */ + asCregURLSearchParameters() { + const params = new URLSearchParams(); + params.set('SearchType', this.type); + + if (this.input) params.set('SearchInput', this.input); + if (this.minPrice) params.set('MinPrice', this.minPrice); + if (this.maxPrice) params.set('MaxPrice', this.maxPrice); + if (this.minBedrooms) params.set('MinBedroomsTotal', this.minBedrooms); + if (this.minBathrooms) params.set('MinBathroomsTotal', this.minBathrooms); + if (this.minSqft) params.set('MinLivingArea', this.minSqft); + if (this.maxSqft) params.set('MaxLivingArea', this.maxSqft); + if (this.keywords && this.keywords.length > 0) params.set('Features', this.keywords.join(',')); + if (this.matchAnyKeyword) params.set('MatchAnyFeatures', 'true'); + if (this.minYear || this.maxYear) { + params.set('YearBuilt', `${this.minYear || 1800}-${this.maxYear || 2100}`); + } + if (this.isNew) params.set('NewListing', 'true'); + if (this.priceChange) params.set('RecentPriceChange', 'true'); + if (this.luxury) params.set('Luxury', 'true'); + if (this.bhhsOnly) params.set('FeaturedCompany', 'BHHS'); + if (this.openHouses) params.set('OpenHouses', this.openHouses.value); + + params.set('Page', this.page); + params.set('PageSize', this.pageSize); + + params.set('ApplicationType', this.listingTypes.map((t) => t.type).join(',')); + params.set('PropertyType', this.propertyTypes.map((t) => t.id).join(',')); + + if (this.franchiseeCode) { + params.set('isFranchisePage', 'true'); + params.set('franchiseeCode', this.franchiseeCode.toUpperCase()); + } + + params.set('Sort', `${this.sortBy}_${this.sortDirection}ENDING`); + return params; + } + + /** + * Returns a URLSearchParameter representing this object. + * @return {URLSearchParams} + */ + asURLSearchParameters() { + const params = []; + Object.entries(this).forEach(([key, value]) => { + if (value) { + if (Array.isArray(value)) { + value.forEach((v) => { + params.push([key, v]); + }); + } else { + params.push([key, value]); + } + } + }); + return new URLSearchParams(params); + } + + populateFromURLSearchParameters(params) { + [...params.entries()].forEach(([k, v]) => { + if (k === 'type') { + return; + } + // Fill object. + if (Object.hasOwn(this, k)) { + this[k] = v; + } + }); + this.listingTypes = params.getAll('listingTypes'); + this.propertyTypes = params.getAll('propertyTypes'); + this.keywords = params.getAll('keywords'); + // Coerce boolean + if (typeof this.isNew === 'string') { + this.isNew = Boolean(this.isNew).valueOf(); + } + if (typeof this.matchAnyKeyword === 'string') { + this.matchAnyKeyword = Boolean(this.matchAnyKeyword).valueOf(); + } + if (typeof this.priceChange === 'string') { + this.priceChange = Boolean(this.priceChange).valueOf(); + } + if (typeof this.luxury === 'string') { + this.luxury = Boolean(this.luxury).valueOf(); + } + if (typeof this.bhhsOnly === 'string') { + this.bhhsOnly = Boolean(this.bhhsOnly).valueOf(); + } + } + + populateFromConfig(entries) { + let entry = entries.find(([k]) => k.match(/min.*price/i)); + if (entry) [, this.minPrice] = entry; + entry = entries.find(([k]) => k.match(/max.*price/i)); + + if (entry) [, this.maxPrice] = entry; + this.isNew = !!entries.find(([k]) => k.match(/new/i)); + + entry = entries.find(([k]) => k.match(/open.*house/i)); + if (entry) this.openHouses = OpenHouses.fromBlockConfig(entry); + + entry = entries.find(([k]) => k.match(/page.*size/i)); + if (entry) [, this.pageSize] = entry; + + this.listingTypes = ListingType.fromBlockConfig(entries.find(([k]) => k.match(/(listing|application).*type/i))); + this.propertyTypes = PropertyType.fromBlockConfig(entries.find(([k]) => k.match(/property.*type/i))); + + entry = entries.find(([k]) => k.match(/sort.*by/i)); + if (entry) this.sortBy = entry[1].toUpperCase(); + + entry = entries.find(([k]) => k.match(/sort.*direction/i)); + if (entry) this.sortDirection = entry[1].toUpperCase(); + } + + /** + * Populates from the Suggestion URL Parameters + * @param {URLSearchParams} params + */ + populateFromSuggestion(params) { + this.input = params.get('SearchInput'); + } + + /** + * Loads the specified search type. + * + * @param {string} type + * @return {Promise} + */ + static async load(type) { + let search = new Search(); + if (type && type !== 'Empty') { + try { + const mod = await import(`./types/${type}Search.js`); + if (mod.default) { + // eslint-disable-next-line new-cap + search = new mod.default(); + } + } catch (error) { + // eslint-disable-next-line no-console + console.log(`failed to load Search Type for ${type}`, error); + } + } + return search; + } + + /** + * Builds a new Search instance from a URL query string + * @param {String} query the query string + * @return {Promise} the search instance + */ + static async fromQueryString(query) { + const params = new URLSearchParams(query); + let search = new Search(); + if (params.has('type') && params.get('type') !== 'Empty') { + const type = params.get('type').replace(/(^\w)|\s+(\w)/g, (letter) => letter.toUpperCase()); + search = await Search.load(type); + } + search.populateFromURLSearchParameters(params); + return search; + } + + /** + * Builds a new Search instance from a Block Config. + * @param {Object} config the block config + * @return {Promise} the search instance + */ + static async fromBlockConfig(config) { + const entries = Object.entries(config); + let search = new Search(); + const typeInput = entries.find(([k]) => k.match(/search.*type/i)); + if (typeInput) { + const type = typeInput[1].replace(/(^\w)|\s+(\w)/g, (letter) => letter.toUpperCase()).replaceAll(/\s/g, ''); + search = await Search.load(type); + } + search.populateFromConfig(entries); + return search; + } + + /** + * Creates Search instance from a JSON Object. + * @param {Object} json + * @return {Promise} the search instance + */ + static async fromJSON(json) { + let search = new Search(); + const { type } = json; + if (type && type !== 'Empty') { + search = await Search.load(type); + } + Object.assign(search, json); + return search; + } +} diff --git a/scripts/apis/creg/search/types/AddressSearch.js b/scripts/apis/creg/search/types/AddressSearch.js new file mode 100644 index 00000000..c727905c --- /dev/null +++ b/scripts/apis/creg/search/types/AddressSearch.js @@ -0,0 +1,28 @@ +import Search from '../Search.js'; + +export default class AddressSearch extends Search { + address; + + constructor() { + super(); + this.type = 'Address'; + } + + asCregURLSearchParameters() { + const params = super.asCregURLSearchParameters(); + params.set('SearchType', 'Address'); + params.set('SearchParameter', this.address); + return params; + } + + populateFromConfig(entries) { + super.populateFromConfig(entries); + const entry = entries.find(([k]) => k.includes('address')); + if (entry) [, this.address] = entry; + } + + populateFromSuggestion(params) { + super.populateFromSuggestion(params); + this.address = params.get('SearchParameter'); + } +} diff --git a/scripts/apis/creg/search/types/BoxSearch.js b/scripts/apis/creg/search/types/BoxSearch.js new file mode 100644 index 00000000..6ab30697 --- /dev/null +++ b/scripts/apis/creg/search/types/BoxSearch.js @@ -0,0 +1,81 @@ +import Search from '../Search.js'; + +export default class BoxSearch extends Search { + #minLat; + + #maxLat; + + #minLon; + + #maxLon; + + constructor() { + super(); + this.type = 'Box'; + Object.defineProperties(this, { + minLat: { + enumerable: true, + set: (value) => { + this.#minLat = `${parseFloat(`${value}`).toFixed(7)}`; + }, + get: () => this.#minLat, + }, + maxLat: { + enumerable: true, + set: (value) => { + this.#maxLat = `${parseFloat(`${value}`).toFixed(7)}`; + }, + get: () => this.#maxLat, + }, + minLon: { + enumerable: true, + set: (value) => { + this.#minLon = `${parseFloat(`${value}`).toFixed(7)}`; + }, + get: () => this.#minLon, + }, + maxLon: { + enumerable: true, + set: (value) => { + this.#maxLon = `${parseFloat(`${value}`).toFixed(7)}`; + }, + get: () => this.#maxLon, + }, + }); + } + + asCregURLSearchParameters() { + const params = super.asCregURLSearchParameters(); + params.set('SearchType', 'Map'); + const obj = { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [[ + [this.minLon, this.minLat], // Bottom left + [this.minLon, this.maxLat], // Top left + [this.maxLon, this.maxLat], // Top right + [this.maxLon, this.minLat], // Bottom right + [this.minLon, this.minLat], // Close the box + ]], + }, + }], + }; + params.set('SearchParameter', JSON.stringify(obj)); + return params; + } + + populateFromConfig(entries) { + super.populateFromConfig(entries); + let entry = entries.find(([k]) => k.includes('min') && k.includes('lat')); + if (entry) [, this.minLat] = entry; + entry = entries.find(([k]) => k.includes('max') && k.includes('lat')); + if (entry) [, this.maxLat] = entry; + entry = entries.find(([k]) => k.includes('min') && k.includes('lon')); + if (entry) [, this.minLon] = entry; + entry = entries.find(([k]) => k.includes('max') && k.includes('lon')); + if (entry) [, this.maxLon] = entry; + } +} diff --git a/scripts/apis/creg/search/types/CitySearch.js b/scripts/apis/creg/search/types/CitySearch.js new file mode 100644 index 00000000..fd318d62 --- /dev/null +++ b/scripts/apis/creg/search/types/CitySearch.js @@ -0,0 +1,28 @@ +import Search from '../Search.js'; + +export default class CitySearch extends Search { + city; + + constructor() { + super(); + this.type = 'City'; + } + + asCregURLSearchParameters() { + const params = super.asCregURLSearchParameters(); + params.set('SearchType', 'City'); + params.set('SearchParameter', this.city); + return params; + } + + populateFromConfig(entries) { + super.populateFromConfig(entries); + const entry = entries.find(([k]) => k.includes('city')); + if (entry) [, this.city] = entry; + } + + populateFromSuggestion(params) { + super.populateFromSuggestion(params); + this.city = params.get('SearchParameter'); + } +} diff --git a/scripts/apis/creg/search/types/ElementarySchoolSearch.js b/scripts/apis/creg/search/types/ElementarySchoolSearch.js new file mode 100644 index 00000000..c83ff476 --- /dev/null +++ b/scripts/apis/creg/search/types/ElementarySchoolSearch.js @@ -0,0 +1,10 @@ +import SchoolSearch from './SchoolSearch.js'; + +export default class ElementarySchoolSearch extends SchoolSearch { + school; + + constructor() { + super(); + this.type = 'ElementarySchool'; + } +} diff --git a/scripts/apis/creg/search/types/HighSchoolSearch.js b/scripts/apis/creg/search/types/HighSchoolSearch.js new file mode 100644 index 00000000..1223df4e --- /dev/null +++ b/scripts/apis/creg/search/types/HighSchoolSearch.js @@ -0,0 +1,10 @@ +import SchoolSearch from './SchoolSearch.js'; + +export default class HighSchoolSearch extends SchoolSearch { + school; + + constructor() { + super(); + this.type = 'HighSchool'; + } +} diff --git a/scripts/apis/creg/search/types/ListingType.js b/scripts/apis/creg/search/types/ListingType.js new file mode 100644 index 00000000..abffdc0b --- /dev/null +++ b/scripts/apis/creg/search/types/ListingType.js @@ -0,0 +1,38 @@ +export default class ListingType { + constructor(type, label) { + this.type = type; + this.label = label; + } + + toString() { + return this.type; + } + + static fromBlockConfig(configEntry) { + const types = []; + if (!configEntry) { + types.push(ListingType.FOR_SALE); + return types; + } + + const [, configStr] = configEntry; + if (configStr.match(/sale/i)) { + types.push(ListingType.FOR_SALE); + } + if (configStr.match(/rent/gi)) { + types.push(ListingType.FOR_RENT); + } + if (configStr.match(/pending/gi)) { + types.push(ListingType.PENDING); + } + if (configStr.match(/sold/gi)) { + types.push(ListingType.RECENTLY_SOLD); + } + return types; + } +} + +ListingType.FOR_SALE = new ListingType('FOR_SALE', 'For Sale'); +ListingType.FOR_RENT = new ListingType('FOR_RENT', 'For Rent'); +ListingType.PENDING = new ListingType('PENDING', 'Pending'); +ListingType.RECENTLY_SOLD = new ListingType('RECENTLY_SOLD', 'Recently Sold'); diff --git a/scripts/apis/creg/search/types/MLSListingKeySearch.js b/scripts/apis/creg/search/types/MLSListingKeySearch.js new file mode 100644 index 00000000..232a54b8 --- /dev/null +++ b/scripts/apis/creg/search/types/MLSListingKeySearch.js @@ -0,0 +1,38 @@ +import Search from '../Search.js'; + +/** + * Special case of search - not to be confused with searching for a specific property based on the listing ID. + * MLS Key searches require the context of the ID to validate franchisee metadata (e.g. vanityDomain) + */ +export default class MLSListingKeySearch extends Search { + listingId; + + context; + + constructor() { + super(); + this.type = 'MLSListingKey'; + } + + asCregURLSearchParameters() { + const params = super.asCregURLSearchParameters(); + params.set('SearchType', 'MLSListingKey'); + params.set('ListingId', this.listingId); + params.set('SearchParameter', this.context); + return params; + } + + populateFromConfig(entries) { + super.populateFromConfig(entries); + let entry = entries.find(([k]) => k.match(/mls.*listing.*key/i)); + if (entry) [, this.listingId] = entry; + entry = entries.find(([k]) => k.match(/context/)); + if (entry) [, this.context] = entry; + } + + populateFromSuggestion(params) { + super.populateFromSuggestion(params); + this.listingId = params.get('ListingId'); + this.context = params.get('SearchParameter'); + } +} diff --git a/scripts/apis/creg/search/types/MiddleSchoolSearch.js b/scripts/apis/creg/search/types/MiddleSchoolSearch.js new file mode 100644 index 00000000..e7207b9d --- /dev/null +++ b/scripts/apis/creg/search/types/MiddleSchoolSearch.js @@ -0,0 +1,10 @@ +import SchoolSearch from './SchoolSearch.js'; + +export default class MiddleSchoolSearch extends SchoolSearch { + school; + + constructor() { + super(); + this.type = 'MiddleSchool'; + } +} diff --git a/scripts/apis/creg/search/types/NeighborhoodSearch.js b/scripts/apis/creg/search/types/NeighborhoodSearch.js new file mode 100644 index 00000000..b61323d6 --- /dev/null +++ b/scripts/apis/creg/search/types/NeighborhoodSearch.js @@ -0,0 +1,28 @@ +import Search from '../Search.js'; + +export default class NeighborhoodSearch extends Search { + neighborhood; + + constructor() { + super(); + this.type = 'Neighborhood'; + } + + asCregURLSearchParameters() { + const params = super.asCregURLSearchParameters(); + params.set('SearchType', 'Neighborhood'); + params.set('SearchParameter', this.neighborhood); + return params; + } + + populateFromConfig(entries) { + super.populateFromConfig(entries); + const entry = entries.find(([k]) => k.includes('neighborhood')); + if (entry) [, this.neighborhood] = entry; + } + + populateFromSuggestion(params) { + super.populateFromSuggestion(params); + this.neighborhood = params.get('SearchParameter'); + } +} diff --git a/scripts/apis/creg/search/types/OpenHouses.js b/scripts/apis/creg/search/types/OpenHouses.js new file mode 100644 index 00000000..996c94ff --- /dev/null +++ b/scripts/apis/creg/search/types/OpenHouses.js @@ -0,0 +1,39 @@ +export default class OpenHouses { + constructor(name, value, label) { + this.name = name; + this.value = value; + this.label = label; + } + + toString() { + return this.name; + } + + static fromBlockConfig(configEntry) { + if (configEntry && /weekend/i.test(configEntry)) { + return OpenHouses.ONLY_WEEKEND; + } + return OpenHouses.ANYTIME; + } + + /** + * Finds the OpenHouse based on the value. + * + * @param value the value of the OpenHouse type + * @return {undefined|OpenHouses} + */ + static fromValue(value) { + // eslint-disable-next-line no-param-reassign + value = (typeof value === 'number') ? value.toFixed(0) : value; + if (value === OpenHouses.ONLY_WEEKEND.value.toFixed(0)) { + return OpenHouses.ONLY_WEEKEND; + } + if (value === OpenHouses.ANYTIME.value.toFixed(0)) { + return OpenHouses.ANYTIME; + } + return undefined; + } +} + +OpenHouses.ONLY_WEEKEND = new OpenHouses('ONLY_WEEKEND', 7, 'This Weekend'); +OpenHouses.ANYTIME = new OpenHouses('ANYTIME', 365, 'Anytime'); diff --git a/scripts/apis/creg/search/types/PolygonSearch.js b/scripts/apis/creg/search/types/PolygonSearch.js new file mode 100644 index 00000000..4a065283 --- /dev/null +++ b/scripts/apis/creg/search/types/PolygonSearch.js @@ -0,0 +1,86 @@ +import Search from '../Search.js'; + +export default class PolygonSearch extends Search { + #points = []; + + constructor() { + super(); + this.type = 'Polygon'; + Object.defineProperties(this, { + points: { + enumerable: true, + set: (value) => { + this.#points.length = 0; + if (value instanceof Array) { + value.forEach((item) => { + const { lat, lon } = item; + if (lat && lon) { + this.#points.push({ lat, lon }); + } + }); + } + }, + get: () => structuredClone(this.#points), + }, + }); + } + + /** + * Add a point to the list of points in this Polygon search. + * @param {Object} point the point + * @param {number|String} point.lat the latitude of the point + * @param {number|String} point.lon the longitude of the point + */ + addPoint(point) { + const { lat, lon } = point; + if (lat && lon) { + this.#points.push({ lat, lon }); + } + } + + asCregURLSearchParameters() { + const coordinates = []; + this.points.forEach((p) => { + coordinates.push([p.lon, p.lat]); + }); + coordinates.push([this.points[0].lon, this.points[0].lat]); + const params = super.asCregURLSearchParameters(); + params.set('SearchType', 'Map'); + const obj = { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [coordinates], + }, + }], + }; + params.set('SearchParameter', JSON.stringify(obj)); + return params; + } + + asURLSearchParameters() { + const params = super.asURLSearchParameters(); + params.delete('points'); + this.#points.forEach((p) => { + params.append('point', `${p.lat},${p.lon}`); + }); + return params; + } + + populateFromURLSearchParameters(params) { + const points = params.getAll('point'); + params.delete('point'); + super.populateFromURLSearchParameters(params); + points.forEach((p) => { + const [lat, lon] = p.split(','); + this.addPoint({ lat, lon }); + }); + } + + // eslint-disable-next-line class-methods-use-this + populateFromConfig() { + throw new Error('PolygonSearch cannot be used in Block config.'); + } +} diff --git a/scripts/apis/creg/search/types/PostalCodeSearch.js b/scripts/apis/creg/search/types/PostalCodeSearch.js new file mode 100644 index 00000000..d6c154f2 --- /dev/null +++ b/scripts/apis/creg/search/types/PostalCodeSearch.js @@ -0,0 +1,28 @@ +import Search from '../Search.js'; + +export default class PostalCodeSearch extends Search { + code; + + constructor() { + super(); + this.type = 'PostalCode'; + } + + asCregURLSearchParameters() { + const params = super.asCregURLSearchParameters(); + params.set('SearchType', 'PostalCode'); + params.set('CoverageZipcode', this.code); + return params; + } + + populateFromConfig(entries) { + super.populateFromConfig(entries); + const entry = entries.find(([k]) => k.match(/postal.*code/i)); + if (entry) [, this.code] = entry; + } + + populateFromSuggestion(params) { + super.populateFromSuggestion(params); + this.code = params.get('CoverageZipcode'); + } +} diff --git a/scripts/apis/creg/search/types/PropertyType.js b/scripts/apis/creg/search/types/PropertyType.js new file mode 100644 index 00000000..9115ef90 --- /dev/null +++ b/scripts/apis/creg/search/types/PropertyType.js @@ -0,0 +1,72 @@ +export default class PropertyType { + constructor(name, value, label) { + this.name = name; + this.id = value; + this.label = label; + } + + toString() { + return this.name; + } + + /** + * Finds the Property type based on the provided id. + * @param {integer} id the id + * @return {PropertyType} the type for the id + */ + static fromId(id) { + // eslint-disable-next-line no-use-before-define + if (id < 0 || id > ALL.length - 1) { + return undefined; + } + // eslint-disable-next-line no-use-before-define + return ALL[id]; + } + + static fromBlockConfig(configEntry) { + const types = []; + if (!configEntry) { + types.push(PropertyType.CONDO_TOWNHOUSE); + types.push(PropertyType.SINGLE_FAMILY); + return types; + } + + const [, configStr] = configEntry; + if (configStr.match(/(condo|townhouse)/i)) { + types.push(PropertyType.CONDO_TOWNHOUSE); + } + if (configStr.match(/single\s+family/gi)) { + types.push(PropertyType.SINGLE_FAMILY); + } + if (configStr.match(/commercial/gi)) { + types.push(PropertyType.COMMERCIAL); + } + if (configStr.match(/multi\s+family/gi)) { + types.push(PropertyType.MULTI_FAMILY); + } + if (configStr.match(/(lot|land)/gi)) { + types.push(PropertyType.LAND); + } + if (configStr.match(/(farm|ranch)/gi)) { + types.push(PropertyType.FARM); + } + return types; + } +} + +PropertyType.CONDO_TOWNHOUSE = new PropertyType('CONDO_TOWNHOUSE', 1, 'Condo/Townhouse'); +PropertyType.SINGLE_FAMILY = new PropertyType('SINGLE_FAMILY', 2, 'Single Family'); +PropertyType.COMMERCIAL = new PropertyType('COMMERCIAL', 3, 'Commercial'); +PropertyType.MULTI_FAMILY = new PropertyType('MULTI_FAMILY', 4, 'Multi Family'); +PropertyType.LAND = new PropertyType('LAND', 5, 'Lot/Land'); +PropertyType.FARM = new PropertyType('FARM', 6, 'Farm/Ranch'); + +const ALL = [ + undefined, // Empty space + PropertyType.CONDO_TOWNHOUSE, + PropertyType.SINGLE_FAMILY, + PropertyType.COMMERCIAL, + PropertyType.MULTI_FAMILY, + PropertyType.LAND, + PropertyType.FARM, +]; diff --git a/scripts/apis/creg/search/types/RadiusSearch.js b/scripts/apis/creg/search/types/RadiusSearch.js new file mode 100644 index 00000000..37ac7d96 --- /dev/null +++ b/scripts/apis/creg/search/types/RadiusSearch.js @@ -0,0 +1,56 @@ +import Search from '../Search.js'; + +export default class RadiusSearch extends Search { + #lat; + + #lon; + + #distance; + + constructor() { + super(); + this.type = 'Radius'; + Object.defineProperties(this, { + lat: { + enumerable: true, + set: (value) => { + this.#lat = `${parseFloat(`${value}`).toFixed(7)}`; + }, + get: () => this.#lat, + }, + lon: { + enumerable: true, + set: (value) => { + this.#lon = `${parseFloat(`${value}`).toFixed(7)}`; + }, + get: () => this.#lon, + }, + distance: { + enumerable: true, + set: (value) => { + this.#distance = `${value}`; + }, + get: () => this.#distance, + }, + }); + } + + asCregURLSearchParameters() { + const params = super.asCregURLSearchParameters(); + params.set('SearchType', this.type); + params.set('Latitude', this.lat); + params.set('Longitude', this.lon); + params.set('Distance', this.distance); + return params; + } + + populateFromConfig(entries) { + super.populateFromConfig(entries); + let entry = entries.find(([k]) => k.includes('lat')); + if (entry) [, this.lat] = entry; + entry = entries.find(([k]) => k.includes('lon')); + if (entry) [, this.lon] = entry; + entry = entries.find(([k]) => k.includes('dist')); + if (entry) [, this.distance] = entry; + } +} diff --git a/scripts/apis/creg/search/types/SchoolDistrictSearch.js b/scripts/apis/creg/search/types/SchoolDistrictSearch.js new file mode 100644 index 00000000..b574e54a --- /dev/null +++ b/scripts/apis/creg/search/types/SchoolDistrictSearch.js @@ -0,0 +1,28 @@ +import Search from '../Search.js'; + +export default class SchoolDistrictSearch extends Search { + district; + + constructor() { + super(); + this.type = 'SchoolDistrict'; + } + + asCregURLSearchParameters() { + const params = super.asCregURLSearchParameters(); + params.set('SearchType', 'SchoolDistrict'); + params.set('SearchParameter', this.district); + return params; + } + + populateFromConfig(entries) { + super.populateFromConfig(entries); + const entry = entries.find(([k]) => k.match(/school.*district/i)); + if (entry) [, this.district] = entry; + } + + populateFromSuggestion(params) { + super.populateFromSuggestion(params); + this.district = params.get('SearchParameter'); + } +} diff --git a/scripts/apis/creg/search/types/SchoolSearch.js b/scripts/apis/creg/search/types/SchoolSearch.js new file mode 100644 index 00000000..d934f74c --- /dev/null +++ b/scripts/apis/creg/search/types/SchoolSearch.js @@ -0,0 +1,23 @@ +import Search from '../Search.js'; + +export default class SchoolSearch extends Search { + school; + + asCregURLSearchParameters() { + const params = super.asCregURLSearchParameters(); + params.set('SearchType', this.type); + params.set('SearchParameter', this.school); + return params; + } + + populateFromConfig(entries) { + super.populateFromConfig(entries); + const entry = entries.find(([k]) => k.includes('school')); + if (entry) [, this.school] = entry; + } + + populateFromSuggestion(params) { + super.populateFromSuggestion(params); + this.school = params.get('SearchParameter'); + } +} diff --git a/scripts/apis/creg/suggestion.js b/scripts/apis/creg/suggestion.js new file mode 100644 index 00000000..50198598 --- /dev/null +++ b/scripts/apis/creg/suggestion.js @@ -0,0 +1,72 @@ +const urlParams = new URLSearchParams(window.location.search); +export const DOMAIN = urlParams.get('env') === 'stage' ? 'ignite-staging.bhhs.com' : 'www.bhhs.com'; +const CREG_API_URL = `https://${DOMAIN}/bin/bhhs`; + +let suggestionFetchController; + +const mapSuggestions = (json) => { + const results = []; + const { searchTypes, suggestions } = json; + + if (!suggestions) { + return results; + } + const keys = Object.keys(suggestions); + keys.forEach((k) => { + if (!suggestions[k.toLowerCase()]) { + suggestions[k.toLowerCase()] = suggestions[k]; + } + }); + searchTypes.forEach((type) => { + let name = type.searchType.replaceAll(/\s+/g, '').toLowerCase(); + if (name === 'zip') name = 'zipcode'; // ZipCode != Zip - and broke somewhere along the way. + if (suggestions[name] && suggestions[name].length) { + results.push({ + ...type, + results: suggestions[name], + }); + } + }); + + return results; +}; + +/** + * Get suggestions for users based on their input and optional country. + * + * @param {String} keyword the partial for suggestion search + * @param {String} [country=undefined] optional country for narrowing search + * + * @return {Promise|undefined} + * Any available suggestions, or undefined if the search was aborted. + */ +export async function get(keyword, country = undefined) { + suggestionFetchController?.abort(); + suggestionFetchController = new AbortController(); + + const { signal } = suggestionFetchController; + + let endpoint = `${CREG_API_URL}/cregSearchSuggesterServlet?Keyword=${keyword}&_=${Date.now()}`; + if (country) { + endpoint += `&Country=${country}`; + } + + return fetch(endpoint, { signal }) + .then((resp) => { + if (resp.ok) { + return resp.json().then(mapSuggestions); + } + // eslint-disable-next-line no-console + console.log('Unable to fetch suggestions.'); + return []; + }).catch((err) => { + if (err.name === 'AbortError') { + return undefined; + } + throw err; + }); +} + +export function abort() { + suggestionFetchController?.abort(); +} diff --git a/scripts/apis/creg/workers/listing.js b/scripts/apis/creg/workers/listing.js new file mode 100644 index 00000000..4872eefa --- /dev/null +++ b/scripts/apis/creg/workers/listing.js @@ -0,0 +1,21 @@ +/** + * Handle the Worker event. Fetches details for each provided listing id. + * + * @param {Object} event the worker event. + * @param {string} event.data.api the URL to fetch. + * @param {string[]} event.data.ids list of listing ids + */ +onmessage = async (event) => { + const { api, ids, officeId } = event.data; + const promises = []; + ids.forEach((id) => { + promises.push( + fetch(`${api}/CregPropertySearchServlet?SearchType=ListingId&ListingId=${id}${officeId ? `&OfficeCode=${officeId}` : ''}`) + .then((resp) => (resp.ok ? resp.json() : undefined)), + ); + }); + + Promise.all(promises).then((values) => { + postMessage(values.filter((v) => v)); + }); +}; diff --git a/scripts/apis/creg/workers/metadata.js b/scripts/apis/creg/workers/metadata.js new file mode 100644 index 00000000..a1b3b156 --- /dev/null +++ b/scripts/apis/creg/workers/metadata.js @@ -0,0 +1,35 @@ +import Search from '../search/Search.js'; + +/** + * Handle the Worker event. + * + * @param {Object} event the worker event. + * @param {string} event.data.api the URL to fetch. + * @param {Search} event.data.searches search context + * + */ +onmessage = async (event) => { + const { api, search } = event.data; + const results = await Search.fromJSON(search) + .then((s) => { + try { + return fetch(`${api}/CregPropertySearchServlet?${s.asCregURLSearchParameters()}`); + } catch (error) { + // eslint-disable-next-line no-console + console.log('Failed to fetch properties from API.', error); + return {}; + } + }) + .then((resp) => { + if (resp.ok) { + return resp.json(); + } + return {}; + }) || {}; + Object.keys(results).forEach((k) => { + if (typeof results[k] === 'object' || Array.isArray(results[k])) { + delete results[k]; + } + }); + postMessage(results); +}; diff --git a/scripts/apis/creg/workers/properties.js b/scripts/apis/creg/workers/properties.js new file mode 100644 index 00000000..0f856ff1 --- /dev/null +++ b/scripts/apis/creg/workers/properties.js @@ -0,0 +1,47 @@ +import Search from '../search/Search.js'; + +/** + * Handle the Worker event. + * + * @param {Object} event the worker event. + * @param {string} event.data.api the URL to fetch. + * @param {Search} event.data.searches search context + */ +onmessage = async (event) => { + const { api, search } = event.data; + const results = await Search.fromJSON(search) + .then((s) => { + try { + return fetch(`${api}/CregPropertySearchServlet?${s.asCregURLSearchParameters()}`); + } catch (error) { + // eslint-disable-next-line no-console + console.log('Failed to fetch properties from API.', error); + return {}; + } + }) + .then((resp) => { + if (resp.ok) { + return resp.json(); + } + return {}; + }) + .catch((error) => { + // eslint-disable-next-line no-console + console.log('Failed to fetch properties from API.', error); + return {}; + }); + if (results) { + const resp = { + properties: results.properties || [], + disclaimer: results.disclaimer.Text, + clusters: results.listingClusters || [], + pins: results.listingPins || [], + pages: results['@odata.context'], + page: search.page, + count: results['@odata.count'], + }; + postMessage(resp); + } else { + postMessage([]); + } +}; diff --git a/scripts/apis/creg/workers/propertySearch.js b/scripts/apis/creg/workers/propertySearch.js deleted file mode 100644 index f8009564..00000000 --- a/scripts/apis/creg/workers/propertySearch.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Handle the Worker event. - * - * @param {Object} event the worker event. - * @param {string} event.data.url the URL to fetch. - * - */ -onmessage = (event) => { - fetch(event.data.url).then(async (resp) => { - if (resp.ok) { - postMessage(await resp.json()); - } else { - postMessage({}); - } - }); -}; diff --git a/scripts/delayed.js b/scripts/delayed.js index 168bdd44..e61213ba 100644 --- a/scripts/delayed.js +++ b/scripts/delayed.js @@ -17,4 +17,10 @@ function loadAdobeLaunch() { }); } -if (!window.location.host.includes('localhost')) loadAdobeLaunch(); +if (!window.location.host.includes('localhost') + && !window.location.host.includes('.hlx.live') + && !window.location.host.includes('.hlx.page') + && !window.location.host.includes('.aem.live') + && !window.location.host.includes('.aem.page')) { + loadAdobeLaunch(); +} diff --git a/scripts/dom-helpers.js b/scripts/dom-helpers.js new file mode 100644 index 00000000..1fce908c --- /dev/null +++ b/scripts/dom-helpers.js @@ -0,0 +1,99 @@ +/* + * Copyright 2023 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* eslint-disable no-param-reassign */ + +/** + * Example Usage: + * + * domEl('main', + * div({ class: 'card' }, + * a({ href: item.path }, + * div({ class: 'card-thumb' }, + * createOptimizedPicture(item.image, item.title, 'lazy', [{ width: '800' }]), + * ), + * div({ class: 'card-caption' }, + * h3(item.title), + * p({ class: 'card-description' }, item.description), + * p({ class: 'button-container' }, + * a({ href: item.path, 'aria-label': 'Read More', class: 'button primary' }, 'Read More'), + * ), + * ), + * ), + * ) + */ + +/** + * Helper for more concisely generating DOM Elements with attributes and children + * @param {string} tag HTML tag of the desired element + * @param {[Object?, ...Element]} items: First item can optionally be an object of attributes, + * everything else is a child element + * @returns {Element} The constructred DOM Element + */ +export function domEl(tag, ...items) { + const element = document.createElement(tag); + + if (!items || items.length === 0) return element; + + if (!(items[0] instanceof Element || items[0] instanceof HTMLElement) && typeof items[0] === 'object') { + const [attributes, ...rest] = items; + items = rest; + + Object.entries(attributes).forEach(([key, value]) => { + if (!key.startsWith('on')) { + element.setAttribute(key, Array.isArray(value) ? value.join(' ') : value); + } else { + element.addEventListener(key.substring(2).toLowerCase(), value); + } + }); + } + + items.forEach((item) => { + item = item instanceof Element || item instanceof HTMLElement + ? item + : document.createTextNode(item); + element.appendChild(item); + }); + + return element; +} + +/* + More short hand functions can be added for very common DOM elements below. + domEl function from above can be used for one off DOM element occurrences. +*/ +export function div(...items) { return domEl('div', ...items); } +export function p(...items) { return domEl('p', ...items); } +export function a(...items) { return domEl('a', ...items); } +export function h1(...items) { return domEl('h1', ...items); } +export function h2(...items) { return domEl('h2', ...items); } +export function h3(...items) { return domEl('h3', ...items); } +export function h4(...items) { return domEl('h4', ...items); } +export function h5(...items) { return domEl('h5', ...items); } +export function h6(...items) { return domEl('h6', ...items); } +export function ul(...items) { return domEl('ul', ...items); } +export function ol(...items) { return domEl('ol', ...items); } +export function li(...items) { return domEl('li', ...items); } +export function i(...items) { return domEl('i', ...items); } +export function img(...items) { return domEl('img', ...items); } +export function span(...items) { return domEl('span', ...items); } +export function form(...items) { return domEl('form', ...items); } +export function input(...items) { return domEl('input', ...items); } +export function label(...items) { return domEl('label', ...items); } +export function button(...items) { return domEl('button', ...items); } +export function iframe(...items) { return domEl('iframe', ...items); } +export function nav(...items) { return domEl('nav', ...items); } +export function fieldset(...items) { return domEl('fieldset', ...items); } +export function article(...items) { return domEl('article', ...items); } +export function strong(...items) { return domEl('strong', ...items); } +export function select(...items) { return domEl('select', ...items); } +export function option(...items) { return domEl('option', ...items); } diff --git a/scripts/scripts.js b/scripts/scripts.js index 2bc96e4f..06b553ce 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -43,6 +43,7 @@ export function preloadHeroImage(picture) { link.setAttribute('type', src.getAttribute('type')); document.head.append(link); } + /** * load fonts.css and set a session storage flag */ @@ -54,6 +55,16 @@ async function loadFonts() { // do nothing } } + +function buildSearchBar(main) { + const metadata = getMetadata('header'); + if (metadata.match(/search bar/i)) { + const section = document.createElement('div'); + section.append(buildBlock('property-search-bar', { elems: [] })); + main.prepend(section); + } +} + /** * Builds hero block and prepends to main in a new section. * @param {Element} main The container element @@ -208,18 +219,6 @@ function buildSeparator(main) { }); } -/** - * Build Property Search Block top nav menu - * @param main - */ -function buildPropertySearchBlock(main) { - if (getMetadata('template') === 'property-search-template') { - const section = document.createElement('div'); - section.append(buildBlock('property-search-bar', { elems: [] })); - main.prepend(section); - } -} - /** * Add luxury collection css for page with template */ @@ -236,12 +235,12 @@ function buildLuxuryTheme() { function buildAutoBlocks(main) { try { buildHeroBlock(main); + buildSearchBar(main); buildLiveByMetadata(main); buildFloatingImages(main); buildSeparator(main); buildBlogDetails(main); buildBlogNav(main); - buildPropertySearchBlock(main); buildLuxuryTheme(); } catch (error) { // eslint-disable-next-line no-console diff --git a/scripts/search.js b/scripts/search.js deleted file mode 100644 index bf46594e..00000000 --- a/scripts/search.js +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Build the query string for the search API - * @returns {string} - */ -export function getSearchObject() { - const search = sessionStorage.getItem('search') ?? '{}'; - return JSON.parse(search); -} - -function buildQueryParameters() { - const search = getSearchObject(); - return Object.keys(search).map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(search[key])}`).join('&'); -} - -/** - * Build the query string for the search API - * @returns {string} - */ -export function buildUrl() { - const host = window.location.origin; - return `${host}/search?${buildQueryParameters()}`; -} - -/** - * public methods for search - * @param key - * @param value - */ -export function setParam(key, value) { - const parameters = getSearchObject(); - parameters[key] = value; - sessionStorage.setItem('search', JSON.stringify(parameters)); -} - -export function getParam(key) { - const parameters = getSearchObject(); - return parameters[key]; -} - -export function removeParam(key) { - const parameters = getSearchObject(); - delete parameters[key]; - sessionStorage.setItem('search', JSON.stringify(parameters)); -} diff --git a/scripts/search/results.js b/scripts/search/results.js deleted file mode 100644 index f503fc06..00000000 --- a/scripts/search/results.js +++ /dev/null @@ -1,35 +0,0 @@ -const event = new Event('onResultUpdated'); - -function getResults() { - return sessionStorage.getItem('result') ? JSON.parse(sessionStorage.getItem('result')) : { - properties: '[]', count: '0', disclaimer: '', listingClusters: '[]', result: '{}', - }; -} - -export function getPropertyDetails() { - return getResults().properties; -} - -export function getPropertiesCount() { - return getResults().count; -} - -export function getDisclaimer() { - return getResults().disclaimer; -} - -export function getListingClusters() { - return getResults().listingClusters; -} - -export function getAllData() { - return getResults().result; -} -/** - * - * @param value - */ -export function setPropertyDetails(value) { - sessionStorage.setItem('result', JSON.stringify(value)); - window.dispatchEvent(event); -} diff --git a/scripts/util.js b/scripts/util.js index 150548dc..dc7724b7 100644 --- a/scripts/util.js +++ b/scripts/util.js @@ -95,6 +95,32 @@ export function getEnvType(hostname = window.location.hostname) { return fqdnToEnvType[hostname] || 'dev'; } +/** + * Format a provided value to a shorthand number. + * From: https://reacthustle.com/blog/how-to-convert-number-to-kmb-format-in-javascript + * @param {String|Number} num the number to format + * @param {Number} precision + */ +export function formatPrice(num, precision = 1) { + if (Number.isNaN(Number.parseFloat(num))) { + // eslint-disable-next-line no-param-reassign + num = Number.parseFloat(num.replaceAll(/,/g, '').replace('$', '')); + } + const map = [ + { suffix: 'T', threshold: 1e12 }, + { suffix: 'B', threshold: 1e9 }, + { suffix: 'M', threshold: 1e6 }, + { suffix: 'k', threshold: 1e3 }, + { suffix: '', threshold: 1 }, + ]; + + const found = map.find((x) => Math.abs(num) >= x.threshold); + if (found) { + return (num / found.threshold).toFixed(precision) + found.suffix; + } + return num; +} + export function phoneFormat(num) { // Remove any non-digit characters from the string let phoneNum = num.replace(/\D/g, ''); diff --git a/styles/images/loading.png b/styles/images/loading.png new file mode 100644 index 00000000..6469b7fe Binary files /dev/null and b/styles/images/loading.png differ diff --git a/styles/styles.css b/styles/styles.css index 957bd55a..aa09cbc9 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -173,6 +173,7 @@ p, span, input { font-weight: var(--font-weight-normal); letter-spacing: var(--letter-spacing-reg); line-height: var(--line-height-m); + color: var(--body-color); } p { @@ -517,7 +518,6 @@ form button[type="submit"]:hover { .section .block.hide-on-mobile { display: none; } - } @media screen and (min-width: 600px) { diff --git a/test/apis/creg/search/Search.test.js b/test/apis/creg/search/Search.test.js new file mode 100644 index 00000000..f469e45a --- /dev/null +++ b/test/apis/creg/search/Search.test.js @@ -0,0 +1,250 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../scripts/apis/creg/search/Search.js'; +import ListingType from '../../../../scripts/apis/creg/search/types/ListingType.js'; +import OpenHouses from '../../../../scripts/apis/creg/search/types/OpenHouses.js'; +import PropertyType from '../../../../scripts/apis/creg/search/types/PropertyType.js'; + +describe('Search', () => { + it('should have defaults', () => { + const search = new Search(); + assert.equal(search.page, '1', 'Default page parameter.'); + assert.equal(search.pageSize, '36', 'Default page size parameter.'); + assert.equal(search.isNew, false, 'Default new listing parameter.'); + assert.deepStrictEqual(search.listingTypes, [ListingType.FOR_SALE], 'Default listing type parameter.'); + assert.deepStrictEqual(search.propertyTypes, [PropertyType.CONDO_TOWNHOUSE, PropertyType.SINGLE_FAMILY], 'Default property type parameter.'); + }); + + it('should populate listing types correctly', () => { + const search = new Search(); + search.listingTypes = [{ type: 'PENDING' }, 'FOR_RENT', { type: 'UNKNOWN' }, { type: 'RECENTLY_SOLD' }]; + assert.deepStrictEqual(search.listingTypes, [ListingType.PENDING, ListingType.FOR_RENT, ListingType.RECENTLY_SOLD], 'Set the listing types correctly.'); + + search.listingTypes = [{ type: 'UNKNOWN' }]; + assert.deepStrictEqual(search.listingTypes, [], 'Does not set unknown type.'); + + search.addListingType({ type: 'UNKNOWN' }); + assert.deepStrictEqual(search.listingTypes, [], 'Does not add unknown type'); + + search.addListingType({ type: 'PENDING' }); + search.addListingType(ListingType.RECENTLY_SOLD); + search.addListingType('FOR_SALE'); + assert.deepStrictEqual(search.listingTypes, [ListingType.PENDING, ListingType.RECENTLY_SOLD, ListingType.FOR_SALE], 'Set the listing types correctly.'); + }); + + it('should populate property types correctly', async () => { + const search = new Search(); + search.propertyTypes = [{ name: 'SINGLE_FAMILY' }, 'COMMERCIAL', { name: 'UNKNOWN' }, { name: 'LAND' }]; + assert.deepStrictEqual(search.propertyTypes, [PropertyType.SINGLE_FAMILY, PropertyType.COMMERCIAL, PropertyType.LAND], 'Set the property types correctly.'); + + search.propertyTypes = [{ name: 'UNKNOWN' }]; + assert.deepStrictEqual(search.propertyTypes, [], 'Does not set unknown type.'); + + search.addPropertyType('UNKNOWN'); + assert.deepStrictEqual(search.propertyTypes, [], 'Does not add unknown type'); + + search.addPropertyType({ id: 6 }); + search.addPropertyType(PropertyType.COMMERCIAL); + search.addPropertyType('LAND'); + assert.deepStrictEqual(search.propertyTypes, [PropertyType.FARM, PropertyType.COMMERCIAL, PropertyType.LAND], 'Set the property types correctly.'); + }); + + describe('create from block config', () => { + it('should create with defaults', async () => { + const search = await Search.fromBlockConfig({}); + assert.deepStrictEqual(search, new Search(), 'Created default instance.'); + }); + + it('support configurations', async () => { + const search = await Search.fromBlockConfig({ + 'Min price': '2000', + 'max price': '2000000', + new: true, + 'Open houses': 'true', + 'page size': '12', + 'listing type': 'pending \n\t\n for rent', + 'pRoPerTy TYPES': '\n\n farm\n\n condo', + 'sort by': 'DATE', + 'sort direction': 'ascending', + }); + + assert.equal(search.minPrice, '2000', 'Min price set'); + assert.equal(search.maxPrice, '2000000', 'Max price set'); + assert(search.isNew, 'New property flag set.'); + assert.equal(search.openHouses, OpenHouses.ANYTIME, 'Open houses set.'); + assert.equal(search.page, '1', 'Page set.'); + assert.equal(search.pageSize, '12', 'Page size set.'); + assert.deepStrictEqual(search.listingTypes, [ListingType.FOR_RENT, ListingType.PENDING], 'Listing types set.'); + assert.deepStrictEqual(search.propertyTypes, [PropertyType.CONDO_TOWNHOUSE, PropertyType.FARM], 'Property types set.'); + assert.equal(search.sortBy, 'DATE', 'Sort type set.'); + assert.equal(search.sortDirection, 'ASC', 'Sort direction set.'); + }); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new Search(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /page=1/, 'Query string includes page parameter.'); + assert.match(queryStr, /pageSize=36/, 'Query string includes page size parameter.'); + assert.match(queryStr, /listingTypes=FOR_SALE/, 'Query string includes listing types parameter.'); + assert.match(queryStr, /propertyTypes=CONDO_TOWNHOUSE&propertyTypes=SINGLE_FAMILY/, 'Query string includes property type parameter.'); + assert.match(queryStr, /sortBy=PRICE/, 'Query string includes sort property parameter.'); + assert.match(queryStr, /sortDirection=DESC/, 'Query string includes sort direction parameter.'); + + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + + it('should support all parameters', async () => { + const search = new Search(); + search.input = 'foo'; + search.minPrice = '10000'; + search.maxPrice = '1000008'; + search.minBedrooms = '6'; + search.minBathrooms = '3'; + search.minSqft = '500'; + search.maxSqft = '1000'; + search.keywords = ['test1', 'test2']; + search.matchAnyKeyword = true; + search.minYear = '1920'; + search.maxYear = '2005'; + search.isNew = true; + search.priceChange = true; + search.openHouses = 'ONLY_WEEKEND'; + search.luxury = true; + search.bhhsOnly = true; + search.page = '2'; + search.pageSize = '8'; + search.listingTypes = [ListingType.FOR_SALE, ListingType.RECENTLY_SOLD]; + search.propertyTypes = [PropertyType.MULTI_FAMILY, PropertyType.LAND]; + search.sortBy = 'DISTANCE'; + search.sortDirection = 'ASC'; + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /input=foo/, 'Query string includes search input parameter.'); + assert.match(queryStr, /minPrice=10000/, 'Query string includes min price.'); + assert.match(queryStr, /maxPrice=1000008/, 'Query string includes max price.'); + assert.match(queryStr, /minBedrooms=6/, 'Query string includes bedrooms.'); + assert.match(queryStr, /minBathrooms=3/, 'Query string includes bathrooms.'); + assert.match(queryStr, /minSqft=500/, 'Query string includes min sqft.'); + assert.match(queryStr, /maxSqft=1000/, 'Query string includes max sqft.'); + assert.match(queryStr, /keywords=test1/, 'Query String includes first keyword'); + assert.match(queryStr, /keywords=test2/, 'Query String includes second keyword'); + assert.match(queryStr, /matchAnyKeyword=true/, 'Query string includes keyword match rule.'); + assert.match(queryStr, /minYear=1920/, 'Query string includes min year.'); + assert.match(queryStr, /maxYear=2005/, 'Query string includes min year.'); + assert.match(queryStr, /isNew=true/, 'Query string includes new listing flag'); + assert.match(queryStr, /priceChange=true/, 'Query string includes price change flag'); + assert.match(queryStr, /openHouses=ONLY_WEEKEND/, 'Query string includes search open house parameter.'); + assert.match(queryStr, /luxury=true/, 'Query string includes luxury parameter.'); + assert.match(queryStr, /bhhsOnly=true/, 'Query string includes BHHS parameter.'); + assert.match(queryStr, /page=2/, 'Query string includes updated page parameter.'); + assert.match(queryStr, /pageSize=8/, 'Query string includes updated page size parameter.'); + assert.match(queryStr, /propertyTypes=MULTI_FAMILY&propertyTypes=LAND/, 'Query string includes Property Types'); + assert.match(queryStr, /listingTypes=FOR_SALE&listingTypes=RECENTLY_SOLD/, 'Query string includes Application Types'); + assert.match(queryStr, /sortBy=DISTANCE/, 'Query string includes sort type'); + assert.match(queryStr, /sortDirection=ASC/, 'Query string includes sort direction.'); + + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new Search(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + + it('should support all parameters', async () => { + const search = new Search(); + search.input = 'foo'; + search.minPrice = '10000'; + search.maxPrice = '1000000'; + search.minBedrooms = '6'; + search.minBathrooms = '3'; + search.minSqft = '500'; + search.maxSqft = '1000'; + search.keywords = ['test1', 'test2']; + search.matchAnyKeyword = true; + search.minYear = '1920'; + search.maxYear = '2005'; + search.isNew = true; + search.openHouses = 'ONLY_WEEKEND'; + search.luxury = true; + search.bhhsOnly = true; + search.page = '2'; + search.pageSize = '8'; + search.listingTypes = [ListingType.FOR_SALE, ListingType.RECENTLY_SOLD]; + search.propertyTypes = [PropertyType.MULTI_FAMILY, PropertyType.LAND]; + search.sortBy = 'DISTANCE'; + search.sortDirection = 'ASC'; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have defaults', () => { + const search = new Search(); + const queryStr = search.asCregURLSearchParameters().toString(); + + assert.match(queryStr, /Page=1/, 'Query string includes updated page parameter.'); + assert.match(queryStr, /PageSize=36/, 'Query string includes updated page size parameter.'); + assert.match(queryStr, /ApplicationType=FOR_SALE/, 'Query string includes Application type parameter.'); + assert.match(queryStr, /PropertyType=1%2C2/, 'Query string includes property type parameter.'); + assert.match(queryStr, /Sort=PRICE_DESCENDING/, 'Query string includes sort parameter.'); + }); + + it('should support all parameters', () => { + const search = new Search(); + search.input = 'foo'; + search.minPrice = '10000'; + search.maxPrice = '1000000'; + search.minBedrooms = '6'; + search.minBathrooms = '3'; + search.minSqft = '500'; + search.maxSqft = '1000'; + search.keywords = ['test1', 'test2']; + search.matchAnyKeyword = true; + search.minYear = '1920'; + search.maxYear = '2005'; + search.isNew = true; + search.priceChange = true; + search.openHouses = 'ONLY_WEEKEND'; + search.luxury = true; + search.bhhsOnly = true; + search.page = '2'; + search.pageSize = '8'; + search.listingTypes = [ListingType.FOR_SALE, ListingType.RECENTLY_SOLD]; + search.propertyTypes = [PropertyType.MULTI_FAMILY, PropertyType.LAND]; + search.sortBy = 'DISTANCE'; + search.sortDirection = 'ASC'; + + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchInput=foo/, 'Query string includes search input parameter.'); + assert.match(queryStr, /MinPrice=10000/, 'Query string includes min price.'); + assert.match(queryStr, /MaxPrice=1000000/, 'Query string includes max price.'); + assert.match(queryStr, /MinBedroomsTotal=6/, 'Query string includes min bedrooms.'); + assert.match(queryStr, /MinBathroomsTotal=3/, 'Query string includes min bathrooms.'); + assert.match(queryStr, /MinLivingArea=500/, 'Query string includes min sqft'); + assert.match(queryStr, /MaxLivingArea=1000/, 'Query string includes max sqft'); + assert.match(queryStr, /Features=test1%2Ctest2/, 'Query string includes feature keywords.'); + assert.match(queryStr, /MatchAnyFeatures=true/, 'Query string include keyword match any'); + assert.match(queryStr, /YearBuilt=1920-2005/, 'Query string includes year built.'); + assert.match(queryStr, /NewListing=true/, 'Query string includes new listing flag'); + assert.match(queryStr, /RecentPriceChange=true/, 'Query string includes price change flag'); + assert.match(queryStr, /OpenHouses=7/, 'Query string includes search open house parameter.'); + assert.match(queryStr, /Luxury=true/, 'Query string includes luxury parameter.'); + assert.match(queryStr, /FeaturedCompany=BHHS/, 'Query string includes BHHS parameter.'); + assert.match(queryStr, /Page=2/, 'Query string includes updated page parameter.'); + assert.match(queryStr, /PageSize=8/, 'Query string includes updated page size parameter.'); + assert.match(queryStr, /PropertyType=4%2C5/, 'Query string includes Property Types'); + assert.match(queryStr, /ApplicationType=FOR_SALE%2CRECENTLY_SOLD/, 'Query string includes Application Types'); + assert.match(queryStr, /Sort=DISTANCE_ASCENDING/, 'Query string includes sort type'); + }); + }); +}); diff --git a/test/apis/creg/search/types/AddressSearch.test.js b/test/apis/creg/search/types/AddressSearch.test.js new file mode 100644 index 00000000..6b28f933 --- /dev/null +++ b/test/apis/creg/search/types/AddressSearch.test.js @@ -0,0 +1,78 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../../scripts/apis/creg/search/Search.js'; +import AddressSearch from '../../../../../scripts/apis/creg/search/types/AddressSearch.js'; + +describe('AddressSearch', () => { + describe('create from block config', () => { + it('should have defaults', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'Address', + }); + assert(search instanceof AddressSearch, 'Created correct type.'); + }); + it('should populate Address specific values', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'address', + address: '123 Elm Street, Nowhere, NO 12345', + }); + assert(search instanceof AddressSearch, 'Created correct type.'); + assert.equal(search.address, '123 Elm Street, Nowhere, NO 12345', 'Address set'); + }); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new AddressSearch(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /type=Address/, 'Query string includes search type parameter.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + it('should read address specific parameters', async () => { + const search = new AddressSearch(); + search.address = '123 Elm Street, Nowhere, NO 12345'; + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /type=Address/, 'Query string includes search type parameter.'); + assert.match(queryStr, /address=123\+Elm\+Street%2C\+Nowhere%2C\+NO\+12345/, 'Query string includes address.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new AddressSearch(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + it('should read address specific parameters', async () => { + const search = new AddressSearch(); + search.address = '123 Elm Street, Nowhere, NO 12345'; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('from suggestion results', () => { + it('should create address search', async () => { + const search = new AddressSearch(); + search.populateFromSuggestion(new URLSearchParams('SearchType\u003dAddress\u0026SearchParameter\u003d123%20Elm%20Street%2C%20Nowhere%2C%20NO%2012345')); + assert(search instanceof AddressSearch, 'Created correct type.'); + assert.equal(search.address, '123 Elm Street, Nowhere, NO 12345', 'Address was correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have address search parameters', () => { + const search = new AddressSearch(); + search.address = '123 Elm Street, Nowhere, NO 12345'; + + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchType=Address/, 'Query string includes search type.'); + assert.match(queryStr, /SearchParameter=123\+Elm\+Street%2C\+Nowhere%2C\+NO\+12345/, 'Query string includes Search parameter structure.'); + }); + }); +}); diff --git a/test/apis/creg/search/types/BoxSearch.test.js b/test/apis/creg/search/types/BoxSearch.test.js new file mode 100644 index 00000000..4ba8bc4d --- /dev/null +++ b/test/apis/creg/search/types/BoxSearch.test.js @@ -0,0 +1,97 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../../scripts/apis/creg/search/Search.js'; +import BoxSearch from '../../../../../scripts/apis/creg/search/types/BoxSearch.js'; + +describe('BoxSearch', () => { + describe('create from block config', () => { + it('should have defaults', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'Box', + }); + assert(search instanceof BoxSearch, 'Created correct type.'); + }); + + it('should populate Box specific values', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'box', + 'min lat': '75.987654321', + 'maximum latitude': '76.987654321', + 'minimum long': '-100.123456789', + 'max longitude': '-101.123456789', + }); + assert(search instanceof BoxSearch, 'Created correct type.'); + assert.equal(search.minLat, '75.9876543', 'Minimum Latitude set.'); + assert.equal(search.maxLat, '76.9876543', 'Maximum Latitude set.'); + assert.equal(search.minLon, '-100.1234568', 'Minimum Longitude set.'); + assert.equal(search.maxLon, '-101.1234568', 'Maximum Longitude set.'); + }); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new BoxSearch(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /type=Box/, 'Query string includes search type parameter.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + + it('should read box specific parameters', async () => { + const search = new BoxSearch(); + search.minLat = 75.987654321; + search.maxLat = 76.987654321; + search.minLon = -100.123456789; + search.maxLon = -101.123456789; + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /type=Box/, 'Query string includes search type parameter.'); + + assert.match(queryStr, /minLat=75.9876543/, 'Query string includes min lat.'); + assert.match(queryStr, /maxLat=76.9876543/, 'Query string includes max lat.'); + assert.match(queryStr, /minLon=-100.1234568/, 'Query string includes min lon.'); + assert.match(queryStr, /maxLon=-101.1234568/, 'Query string includes max lon.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new BoxSearch(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + + it('should read box specific parameters', async () => { + const search = new BoxSearch(); + search.minLat = 75.987654321; + search.maxLat = 76.987654321; + search.minLon = -100.123456789; + search.maxLon = -101.123456789; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have Box search parameters', () => { + const search = new BoxSearch(); + search.minLat = 75.987654321; + search.maxLat = 76.987654321; + search.minLon = -100.123456789; + search.maxLon = -101.123456789; + + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchType=Map/, 'Query string includes search type.'); + assert.match(queryStr, /SearchParameter=%7B%22type%22%3A%22FeatureCollection%22%2C%22features%22%3A%5B%7B%22type%22%3A%22Feature%22%2C%/, 'Query string includes Base structure.'); + assert.match(queryStr, /22geometry%22%3A%7B%22type%22%3A%22Polygon%22%2C%22coordinates%22%3A%5B%5B%5B%22/, 'Query string includes geometry beginning.'); + assert.match(queryStr, /%22%3A%5B%5B%5B%22-100.1234568%22%2C%2275.9876543/, 'Query string includes first minLon/minLat point.'); + assert.match(queryStr, /-100.1234568%22%2C%2276.9876543/, 'Query string includes minLon/maxLat point.'); + assert.match(queryStr, /-101.1234568%22%2C%2276.9876543/, 'Query string includes maxLon/maxLat point.'); + assert.match(queryStr, /-101.1234568%22%2C%2275.9876543/, 'Query string includes maxLon/minLat point.'); + assert.match(queryStr, /-100.1234568%22%2C%2275.9876543%22%5D%5D%5D%7D%7D%5D%7D/, 'Query string includes close-the-box minLon/minLat point.'); + }); + }); +}); diff --git a/test/apis/creg/search/types/CitySearch.test.js b/test/apis/creg/search/types/CitySearch.test.js new file mode 100644 index 00000000..fa41819c --- /dev/null +++ b/test/apis/creg/search/types/CitySearch.test.js @@ -0,0 +1,78 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../../scripts/apis/creg/search/Search.js'; +import CitySearch from '../../../../../scripts/apis/creg/search/types/CitySearch.js'; + +describe('CitySearch', () => { + describe('create from block config', () => { + it('should have defaults', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'City', + }); + assert(search instanceof CitySearch, 'Created correct type.'); + }); + it('should populate City specific values', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'city', + city: 'Nowhere, NO', + }); + assert(search instanceof CitySearch, 'Created correct type.'); + assert.equal(search.city, 'Nowhere, NO', 'City set'); + }); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new CitySearch(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /type=City/, 'Query string includes search type parameter.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + it('should read city specific parameters', async () => { + const search = new CitySearch(); + search.city = 'Nowhere, NO'; + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /type=City/, 'Query string includes search type parameter.'); + assert.match(queryStr, /city=Nowhere%2C\+NO/, 'Query string includes city.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new CitySearch(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + it('should read city specific parameters', async () => { + const search = new CitySearch(); + search.city = 'Nowhere, NO'; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('from suggestion results', () => { + it('should create city search', async () => { + const search = new CitySearch(); + search.populateFromSuggestion(new URLSearchParams('SearchType\u003dCity\u0026SearchParameter\u003dNowhere%2C%20NO')); + assert(search instanceof CitySearch, 'Created correct type.'); + assert.equal(search.city, 'Nowhere, NO', 'City was correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have city search parameters', () => { + const search = new CitySearch(); + search.city = 'Nowhere, NO'; + + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchType=City/, 'Query string includes search type.'); + assert.match(queryStr, /SearchParameter=Nowhere%2C\+NO/, 'Query string includes Search parameter structure.'); + }); + }); +}); diff --git a/test/apis/creg/search/types/ElementarySchoolSearch.test.js b/test/apis/creg/search/types/ElementarySchoolSearch.test.js new file mode 100644 index 00000000..5745c21a --- /dev/null +++ b/test/apis/creg/search/types/ElementarySchoolSearch.test.js @@ -0,0 +1,78 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../../scripts/apis/creg/search/Search.js'; +import ElementarySchoolSearch from '../../../../../scripts/apis/creg/search/types/ElementarySchoolSearch.js'; + +describe('ElementarySchoolSearch', () => { + describe('create from block config', () => { + it('should have defaults', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'Elementary School', + }); + assert(search instanceof ElementarySchoolSearch, 'Created correct type.'); + }); + it('should populate ElementarySchool specific values', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'elementary school', + 'elementary school': 'Elementary School on elm street', + }); + assert(search instanceof ElementarySchoolSearch, 'Created correct type.'); + assert.equal(search.school, 'Elementary School on elm street', 'ElementarySchool set'); + }); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new ElementarySchoolSearch(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /type=ElementarySchool/, 'Query string includes search type parameter.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + it('should read School specific parameters', async () => { + const search = new ElementarySchoolSearch(); + search.school = 'Elementary School on elm street'; + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /type=ElementarySchool/, 'Query string includes search type parameter.'); + assert.match(queryStr, /school=Elementary\+School\+on\+elm\+street/, 'Query string includes elementary school.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new ElementarySchoolSearch(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + it('should read school specific parameters', async () => { + const search = new ElementarySchoolSearch(); + search.school = 'Elementary School on elm street'; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('from suggestion results', () => { + it('should create elementary school search', async () => { + const search = new ElementarySchoolSearch(); + search.populateFromSuggestion(new URLSearchParams('SearchType\u003dElementary%20School\u0026SearchParameter\u003dElementary%20School%20on%20elm%20street')); + assert(search instanceof ElementarySchoolSearch, 'Created correct type.'); + assert.equal(search.school, 'Elementary School on elm street', 'School was correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have school search parameters', () => { + const search = new ElementarySchoolSearch(); + search.school = 'Elementary School on elm street'; + + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchType=ElementarySchool/, 'Query string includes search type.'); + assert.match(queryStr, /SearchParameter=Elementary\+School\+on\+elm\+street/, 'Query string includes Search parameter structure.'); + }); + }); +}); diff --git a/test/apis/creg/search/types/HighSchoolSearch.test.js b/test/apis/creg/search/types/HighSchoolSearch.test.js new file mode 100644 index 00000000..7007e34a --- /dev/null +++ b/test/apis/creg/search/types/HighSchoolSearch.test.js @@ -0,0 +1,78 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../../scripts/apis/creg/search/Search.js'; +import HighSchoolSearch from '../../../../../scripts/apis/creg/search/types/HighSchoolSearch.js'; + +describe('HighSchoolSearch', () => { + describe('create from block config', () => { + it('should have defaults', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'High School', + }); + assert(search instanceof HighSchoolSearch, 'Created correct type.'); + }); + it('should populate HighSchool specific values', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'High School', + 'high school': 'High School on elm street', + }); + assert(search instanceof HighSchoolSearch, 'Created correct type.'); + assert.equal(search.school, 'High School on elm street', 'HighSchool set'); + }); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new HighSchoolSearch(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /type=HighSchool/, 'Query string includes search type parameter.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + it('should read school specific parameters', async () => { + const search = new HighSchoolSearch(); + search.school = 'High School on elm street'; + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /type=HighSchool/, 'Query string includes search type parameter.'); + assert.match(queryStr, /school=High\+School\+on\+elm\+street/, 'Query string includes school.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new HighSchoolSearch(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + it('should read school specific parameters', async () => { + const search = new HighSchoolSearch(); + search.school = 'High School on elm street'; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('from suggestion results', () => { + it('should create high school search', async () => { + const search = new HighSchoolSearch(); + search.populateFromSuggestion(new URLSearchParams('SearchType\u003dHigh%20School\u0026SearchParameter\u003dHigh%20School%20on%20elm%20street')); + assert(search instanceof HighSchoolSearch, 'Created correct type.'); + assert.equal(search.school, 'High School on elm street', 'School was correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have school search parameters', () => { + const search = new HighSchoolSearch(); + search.school = 'High School on elm street'; + + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchType=HighSchool/, 'Query string includes search type.'); + assert.match(queryStr, /SearchParameter=High\+School\+on\+elm\+street/, 'Query string includes Search parameter structure.'); + }); + }); +}); diff --git a/test/apis/creg/search/types/MLSListingKeySearch.test.js b/test/apis/creg/search/types/MLSListingKeySearch.test.js new file mode 100644 index 00000000..4283a4bd --- /dev/null +++ b/test/apis/creg/search/types/MLSListingKeySearch.test.js @@ -0,0 +1,87 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../../scripts/apis/creg/search/Search.js'; +import MLSListingKeySearch from '../../../../../scripts/apis/creg/search/types/MLSListingKeySearch.js'; + +describe('MLSListingKeySearch', () => { + describe('create from block config', () => { + it('should have defaults', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'MLS Listing Key', + }); + assert(search instanceof MLSListingKeySearch, 'Created correct type.'); + }); + it('should populate MLS Listing Key specific values', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'MLS listing key', + 'MLS Listing Key': '12345678', + context: 'Boston, MA', + }); + assert(search instanceof MLSListingKeySearch, 'Created correct type.'); + assert.equal(search.listingId, '12345678', 'MLS Listing Key set'); + assert.equal(search.context, 'Boston, MA'); + }); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new MLSListingKeySearch(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /type=MLSListingKey/, 'Query string includes search type parameter.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + it('should read address specific parameters', async () => { + const search = new MLSListingKeySearch(); + search.listingId = '12345678'; + search.context = 'Boston, MA'; + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /type=MLSListingKey/, 'Query string includes search type parameter.'); + assert.match(queryStr, /listingId=12345678/, 'Query string includes listing id.'); + assert.match(queryStr, /context=Boston%2C\+MA/, 'Query string includes context.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new MLSListingKeySearch(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + it('should read address specific parameters', async () => { + const search = new MLSListingKeySearch(); + search.listingId = '12345678'; + search.context = 'Boston, MA'; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('from suggestion results', () => { + it('should create MLS search', async () => { + const search = new MLSListingKeySearch(); + search.populateFromSuggestion(new URLSearchParams('ListingId=12345678\u0026SearchType\u003dMLSListingKey\u0026SearchParameter\u003dBoston%2C%20MA')); + + assert(search instanceof MLSListingKeySearch, 'Created correct type.'); + assert.equal(search.listingId, '12345678', 'Listing id was correct.'); + assert.equal(search.context, 'Boston, MA', 'Context was correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have address search parameters', () => { + const search = new MLSListingKeySearch(); + search.listingId = '12345678'; + search.context = 'Boston, MA'; + + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchType=MLSListingKey/, 'Query string includes search type.'); + assert.match(queryStr, /ListingId=12345678/, 'Query string includes listing id'); + assert.match(queryStr, /SearchParameter=Boston%2C\+MA/, 'Query string includes Search parameter structure.'); + }); + }); +}); diff --git a/test/apis/creg/search/types/MiddleSchoolSearch.test.js b/test/apis/creg/search/types/MiddleSchoolSearch.test.js new file mode 100644 index 00000000..9398b075 --- /dev/null +++ b/test/apis/creg/search/types/MiddleSchoolSearch.test.js @@ -0,0 +1,78 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../../scripts/apis/creg/search/Search.js'; +import MiddleSchoolSearch from '../../../../../scripts/apis/creg/search/types/MiddleSchoolSearch.js'; + +describe('MiddleSchoolSearch', () => { + describe('create from block config', () => { + it('should have defaults', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'Middle School', + }); + assert(search instanceof MiddleSchoolSearch, 'Created correct type.'); + }); + it('should populate MiddleSchool specific values', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'middle School', + 'middle school': 'Middle School on elm street', + }); + assert(search instanceof MiddleSchoolSearch, 'Created correct type.'); + assert.equal(search.school, 'Middle School on elm street', 'MiddleSchool set'); + }); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new MiddleSchoolSearch(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /type=MiddleSchool/, 'Query string includes search type parameter.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + it('should read school specific parameters', async () => { + const search = new MiddleSchoolSearch(); + search.school = 'Middle School on elm street'; + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /type=MiddleSchool/, 'Query string includes search type parameter.'); + assert.match(queryStr, /school=Middle\+School\+on\+elm\+street/, 'Query string includes school.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new MiddleSchoolSearch(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + it('should read school specific parameters', async () => { + const search = new MiddleSchoolSearch(); + search.school = 'Middle School on elm street'; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('from suggestion results', () => { + it('should create middle school search', async () => { + const search = new MiddleSchoolSearch(); + search.populateFromSuggestion(new URLSearchParams('SearchType\u003dMiddle%20School\u0026SearchParameter\u003dMiddle%20School%20on%20elm%20street')); + assert(search instanceof MiddleSchoolSearch, 'Created correct type.'); + assert.equal(search.school, 'Middle School on elm street', 'School was correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have school search parameters', () => { + const search = new MiddleSchoolSearch(); + search.school = 'Middle School on elm street'; + + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchType=MiddleSchool/, 'Query string includes search type.'); + assert.match(queryStr, /SearchParameter=Middle\+School\+on\+elm\+street/, 'Query string includes Search parameter structure.'); + }); + }); +}); diff --git a/test/apis/creg/search/types/NeighborhoodSearch.test.js b/test/apis/creg/search/types/NeighborhoodSearch.test.js new file mode 100644 index 00000000..e22592d3 --- /dev/null +++ b/test/apis/creg/search/types/NeighborhoodSearch.test.js @@ -0,0 +1,78 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../../scripts/apis/creg/search/Search.js'; +import NeighborhoodSearch from '../../../../../scripts/apis/creg/search/types/NeighborhoodSearch.js'; + +describe('NeighborhoodSearch', () => { + describe('create from block config', () => { + it('should have defaults', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'Neighborhood', + }); + assert(search instanceof NeighborhoodSearch, 'Created correct type.'); + }); + it('should populate Neighborhood specific values', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'neighborhood', + neighborhood: '123 Elm Street, Nowhere, NO 12345', + }); + assert(search instanceof NeighborhoodSearch, 'Created correct type.'); + assert.equal(search.neighborhood, '123 Elm Street, Nowhere, NO 12345', 'Neighborhood set'); + }); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new NeighborhoodSearch(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /type=Neighborhood/, 'Query string includes search type parameter.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + it('should read neighborhood specific parameters', async () => { + const search = new NeighborhoodSearch(); + search.neighborhood = '123 Elm Street, Nowhere, NO 12345'; + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /type=Neighborhood/, 'Query string includes search type parameter.'); + assert.match(queryStr, /neighborhood=123\+Elm\+Street%2C\+Nowhere%2C\+NO\+12345/, 'Query string includes neighborhood.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new NeighborhoodSearch(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + it('should read neighborhood specific parameters', async () => { + const search = new NeighborhoodSearch(); + search.neighborhood = '123 Elm Street, Nowhere, NO 12345'; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('from suggestion results', () => { + it('should create Neighborhood search', async () => { + const search = new NeighborhoodSearch(); + search.populateFromSuggestion(new URLSearchParams('SearchType\u003dNeighborhood\u0026SearchParameter\u003d123%20Elm%20Street%2C%20Nowhere%2C%20NO%2012345')); + assert(search instanceof NeighborhoodSearch, 'Created correct type.'); + assert.equal(search.neighborhood, '123 Elm Street, Nowhere, NO 12345', 'Neighborhood was correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have neighborhood search parameters', () => { + const search = new NeighborhoodSearch(); + search.neighborhood = '123 Elm Street, Nowhere, NO 12345'; + + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchType=Neighborhood/, 'Query string includes search type.'); + assert.match(queryStr, /SearchParameter=123\+Elm\+Street%2C\+Nowhere%2C\+NO\+12345/, 'Query string includes Search parameter structure.'); + }); + }); +}); diff --git a/test/apis/creg/search/types/PolygonSearch.test.js b/test/apis/creg/search/types/PolygonSearch.test.js new file mode 100644 index 00000000..d0bcf31e --- /dev/null +++ b/test/apis/creg/search/types/PolygonSearch.test.js @@ -0,0 +1,119 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../../scripts/apis/creg/search/Search.js'; +import PolygonSearch from '../../../../../scripts/apis/creg/search/types/PolygonSearch.js'; + +describe('PolygonSearch', () => { + it('cannot be created create from block config', async () => { + await assert.rejects(Search.fromBlockConfig({ + 'search type': 'Polygon', + }), Error, 'Does not create instance.'); + }); + + it('can add a point', () => { + const search = new PolygonSearch(); + search.addPoint('Not An object.'); + assert.deepStrictEqual(search.points, [], 'Ignored invalid object.'); + search.addPoint({}); + assert.deepStrictEqual(search.points, [], 'Ignored invalid object.'); + search.addPoint([]); + assert.deepStrictEqual(search.points, [], 'Ignored invalid object.'); + search.addPoint({ lat: '123' }); + assert.deepStrictEqual(search.points, [], 'Ignored invalid object.'); + search.addPoint({ lon: '123' }); + assert.deepStrictEqual(search.points, [], 'Ignored invalid object.'); + + search.addPoint({ lat: '123', lon: '123' }); + assert.deepStrictEqual(search.points, [{ lat: '123', lon: '123' }], 'Ignored invalid object.'); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new PolygonSearch(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /type=Polygon/, 'Query string includes search type parameter.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + + it('should read Polygon specific parameters', async () => { + const search = new PolygonSearch(); + search.addPoint({ lat: '42.14299778443187', lon: '-70.99324744939803' }); + search.addPoint({ lat: '42.038540372268336', lon: '-71.10242408514021' }); + search.addPoint({ lat: '41.941061865983954', lon: '-70.96578162908553' }); + search.addPoint({ lat: '41.928291852269396', lon: '-70.72339576482771' }); + search.addPoint({ lat: '42.01150632664794', lon: '-70.67807716131209' }); + search.addPoint({ lat: '42.10785815410172', lon: '-70.74536842107771' }); + search.addPoint({ lat: '42.16946694504556', lon: '-70.71515601873396' }); + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /type=Polygon/, 'Query string includes search type parameter.'); + + assert.match(queryStr, /point=42.14299778443187%2C-70.99324744939803/, 'Query string includes first point'); + assert.match(queryStr, /point=42.038540372268336%2C-71.10242408514021/, 'Query string includes second point'); + assert.match(queryStr, /point=41.941061865983954%2C-70.96578162908553/, 'Query string includes third point'); + assert.match(queryStr, /point=41.928291852269396%2C-70.72339576482771/, 'Query string includes fourth point'); + assert.match(queryStr, /point=42.01150632664794%2C-70.67807716131209/, 'Query string includes fifth point'); + assert.match(queryStr, /point=42.10785815410172%2C-70.74536842107771/, 'Query string includes sixth point'); + assert.match(queryStr, /point=42.16946694504556%2C-70.71515601873396/, 'Query string includes seventh point'); + + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new PolygonSearch(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + + it('should read polygon specific parameters', async () => { + const points = [ + { lat: '42.14299778443187', lon: '-70.99324744939803' }, + { lat: '42.038540372268336', lon: '-71.10242408514021' }, + { lat: '41.941061865983954', lon: '-70.96578162908553' }, + { lat: '41.928291852269396', lon: '-70.72339576482771' }, + { lat: '42.01150632664794', lon: '-70.67807716131209' }, + { lat: '42.10785815410172', lon: '-70.74536842107771' }, + { lat: '42.16946694504556', lon: '-70.71515601873396' }, + ]; + + const search = new PolygonSearch(); + search.points = points; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have Polygon search parameters', () => { + const points = [ + { lat: '42.14299778443187', lon: '-70.99324744939803' }, + { lat: '42.038540372268336', lon: '-71.10242408514021' }, + { lat: '41.941061865983954', lon: '-70.96578162908553' }, + { lat: '41.928291852269396', lon: '-70.72339576482771' }, + { lat: '42.01150632664794', lon: '-70.67807716131209' }, + { lat: '42.10785815410172', lon: '-70.74536842107771' }, + { lat: '42.16946694504556', lon: '-70.71515601873396' }, + ]; + const search = new PolygonSearch(); + search.points = points; + + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchType=Map/, 'Query string includes search type.'); + assert.match(queryStr, /SearchParameter=%7B%22type%22%3A%22FeatureCollection%22%2C%22features%22%3A%5B%7B%22type%22%3A%22Feature%22%2C%/, 'Query string includes Base structure.'); + assert.match(queryStr, /22geometry%22%3A%7B%22type%22%3A%22Polygon%22%2C%22coordinates%22%3A%5B%5B%5B%22/, 'Query string includes geometry beginning.'); + assert.match(queryStr, /%22%3A%5B%5B%5B%22-70.99324744939803%22%2C%2242.14299778443187/, 'Query string includes first point.'); + assert.match(queryStr, /-71.10242408514021%22%2C%2242.038540372268336/, 'Query string includes second point.'); + assert.match(queryStr, /-70.96578162908553%22%2C%2241.941061865983954/, 'Query string includes third point.'); + assert.match(queryStr, /-70.72339576482771%22%2C%2241.928291852269396/, 'Query string includes fourth point.'); + assert.match(queryStr, /-70.67807716131209%22%2C%2242.01150632664794/, 'Query string includes fifth point.'); + assert.match(queryStr, /-70.74536842107771%22%2C%2242.10785815410172/, 'Query string includes sixth point.'); + assert.match(queryStr, /-70.71515601873396%22%2C%2242.16946694504556/, 'Query string includes seventh point.'); + assert.match(queryStr, /-70.99324744939803%22%2C%2242.14299778443187%22%5D%5D%5D%7D%7D%5D%7D/, 'Query string includes closing point.'); + }); + }); +}); diff --git a/test/apis/creg/search/types/PostalCodeSearch.test.js b/test/apis/creg/search/types/PostalCodeSearch.test.js new file mode 100644 index 00000000..8b6e4d32 --- /dev/null +++ b/test/apis/creg/search/types/PostalCodeSearch.test.js @@ -0,0 +1,78 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../../scripts/apis/creg/search/Search.js'; +import PostalCodeSearch from '../../../../../scripts/apis/creg/search/types/PostalCodeSearch.js'; + +describe('PostalCodeSearch', () => { + describe('create from block config', () => { + it('should have defaults', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'postal code', + }); + assert(search instanceof PostalCodeSearch, 'Created correct type from postal code.'); + }); + it('should populate PostalCode specific values', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'postal Code', + 'postAl coDe': '12345', + }); + assert(search instanceof PostalCodeSearch, 'Created correct type.'); + assert.equal(search.code, '12345', 'PostalCode set'); + }); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new PostalCodeSearch(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /type=PostalCode/, 'Query string includes search type parameter.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + it('should read postal code specific parameters', async () => { + const search = new PostalCodeSearch(); + search.code = '12345'; + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /type=PostalCode/, 'Query string includes search type parameter.'); + assert.match(queryStr, /code=12345/, 'Query string includes postal code.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new PostalCodeSearch(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + it('should read postal code specific parameters', async () => { + const search = new PostalCodeSearch(); + search.code = '12345'; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('from suggestion results', () => { + it('should create Postal Code search', async () => { + const search = new PostalCodeSearch(); + search.populateFromSuggestion(new URLSearchParams('SearchType\u003dPostalCode\u0026CoverageZipcode\u003d12345')); + assert(search instanceof PostalCodeSearch, 'Created correct type.'); + assert.equal(search.code, '12345', 'Postal Code was correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have postal code search parameters', () => { + const search = new PostalCodeSearch(); + search.code = '12345'; + + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchType=PostalCode/, 'Query string includes search type.'); + assert.match(queryStr, /CoverageZipcode=12345/, 'Query string includes Search parameter structure.'); + }); + }); +}); diff --git a/test/apis/creg/search/types/RadiusSearch.test.js b/test/apis/creg/search/types/RadiusSearch.test.js new file mode 100644 index 00000000..b3f8534d --- /dev/null +++ b/test/apis/creg/search/types/RadiusSearch.test.js @@ -0,0 +1,86 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../../scripts/apis/creg/search/Search.js'; +import RadiusSearch from '../../../../../scripts/apis/creg/search/types/RadiusSearch.js'; +import BoxSearch from '../../../../../scripts/apis/creg/search/types/BoxSearch.js'; + +describe('RadiusSearch', () => { + describe('create from block config', () => { + it('should have defaults', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'Radius', + }); + assert(search instanceof RadiusSearch, 'Created correct type.'); + }); + + it('should populate Radius specific values', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'radius', + lat: 75.987654321, + longitude: -101.123456789, + dist: 5, + }); + assert(search instanceof RadiusSearch, 'Created correct type.'); + assert.equal(search.lat, '75.9876543', 'Longitude set.'); + assert.equal(search.lon, '-101.1234568', 'Latitude set.'); + assert.equal(search.distance, '5', 'Distance set.'); + }); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new RadiusSearch(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /type=Radius/, 'Query string includes search type parameter.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + + it('should read radius specific parameters', async () => { + const search = new RadiusSearch(); + search.lat = 75.987654321; + search.lon = -101.123456789; + search.distance = 10; + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /type=Radius/, 'Query string includes search type parameter.'); + assert.match(queryStr, /lat=75.9876543/, 'Query string includes lat.'); + assert.match(queryStr, /lon=-101.1234568/, 'Query string includes lon.'); + assert.match(queryStr, /distance=10/, 'Query string includes distance'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new BoxSearch(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + + it('should read radius specific parameters', async () => { + const search = new RadiusSearch(); + search.lat = 75.987654321; + search.lon = -101.123456789; + search.distance = 10; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have Radius search parameters', () => { + const search = new RadiusSearch(); + search.lat = 75.987654321; + search.lon = -101.123456789; + search.distance = 10; + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchType=Radius/, 'Query string includes search type.'); + assert.match(queryStr, /Latitude=75.9876543/, 'Query string includes lat.'); + assert.match(queryStr, /Longitude=-101.1234568/, 'Query string includes lon.'); + assert.match(queryStr, /Distance=10/, 'Query string includes distance'); + }); + }); +}); diff --git a/test/apis/creg/search/types/SchoolDistrictSearch.test.js b/test/apis/creg/search/types/SchoolDistrictSearch.test.js new file mode 100644 index 00000000..a6139095 --- /dev/null +++ b/test/apis/creg/search/types/SchoolDistrictSearch.test.js @@ -0,0 +1,78 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; +import Search from '../../../../../scripts/apis/creg/search/Search.js'; +import SchoolDistrictSearch from '../../../../../scripts/apis/creg/search/types/SchoolDistrictSearch.js'; + +describe('SchoolDistrictSearch', () => { + describe('create from block config', () => { + it('should have defaults', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'School District', + }); + assert(search instanceof SchoolDistrictSearch, 'Created correct type.'); + }); + it('should populate SchoolDistrict specific values', async () => { + const search = await Search.fromBlockConfig({ + 'search type': 'school district', + 'school district': 'School District in Nowhere, NO', + }); + assert(search instanceof SchoolDistrictSearch, 'Created correct type.'); + assert.equal(search.district, 'School District in Nowhere, NO', 'School District set'); + }); + }); + + describe('to/from URL Search Parameters', () => { + it('should have defaults', async () => { + const search = new SchoolDistrictSearch(); + const queryStr = search.asURLSearchParameters().toString(); + + assert.match(queryStr, /type=SchoolDistrict/, 'Query string includes search type parameter.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + it('should read school district specific parameters', async () => { + const search = new SchoolDistrictSearch(); + search.district = 'School District in Nowhere, NO'; + + const queryStr = search.asURLSearchParameters().toString(); + assert.match(queryStr, /type=SchoolDistrict/, 'Query string includes search type parameter.'); + assert.match(queryStr, /district=School\+District\+in\+Nowhere%2C\+NO/, 'Query string includes school district.'); + const created = await Search.fromQueryString(queryStr); + assert.deepStrictEqual(created, search, 'Object was parsed from query string correctly.'); + }); + }); + + describe('to/from JSON', () => { + it('should have defaults', async () => { + const search = new SchoolDistrictSearch(); + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + it('should read school district specific parameters', async () => { + const search = new SchoolDistrictSearch(); + search.district = 'School District in Nowhere, NO'; + const created = await Search.fromJSON(JSON.parse(JSON.stringify(search))); + assert.deepStrictEqual(created, search, 'To/From JSON correct.'); + }); + }); + + describe('from suggestion results', () => { + it('should create school district search', async () => { + const search = new SchoolDistrictSearch(); + search.populateFromSuggestion(new URLSearchParams('SearchType\u003dSchoolDistrict\u0026SearchParameter\u003dSchool%20District%20in%20Nowhere%2C%20NO')); + assert(search instanceof SchoolDistrictSearch, 'Created correct type.'); + assert.equal(search.district, 'School District in Nowhere, NO', 'School district was correct.'); + }); + }); + + describe('to CREG URL Search Parameters', () => { + it('should have school district search parameters', () => { + const search = new SchoolDistrictSearch(); + search.district = 'School District in Nowhere, NO'; + + const queryStr = search.asCregURLSearchParameters().toString(); + assert.match(queryStr, /SearchType=SchoolDistrict/, 'Query string includes search type.'); + assert.match(queryStr, /SearchParameter=School\+District\+in\+Nowhere%2C\+NO/, 'Query string includes Search parameter structure.'); + }); + }); +});