From 9304fb16136654ec9e39cb1bf3b7da8e85d5bd5d Mon Sep 17 00:00:00 2001 From: Bryan Stopp <bstopp@users.noreply.github.com> Date: Mon, 20 May 2024 09:24:53 -0700 Subject: [PATCH] Feat/property search improvements (#199) * Some improvements to loading and Accessibility. * Fix icons. * More icon fixing. * Need to test intermediate logic on site. * Remove references to partytown. (#178) * add style for section top-border (#179) Co-authored-by: Bryan Stopp <bstopp@users.noreply.github.com> * added style to override margin bottom (#180) Co-authored-by: Bryan Stopp <bstopp@users.noreply.github.com> * update host for dev publishing (#182) * starter hero slides * mocked listings * Fix images in default content. (#184) * fix the gradiant overlay, less opacity (#185) * Update styles on Shade Icon card variation. (#186) * css layout * hide paging * rearrange functions * disable linting on specific functions * css linting * style updates * added class that scales images * js linting * added section full-bleed to allow full-witdh scaled image * create div for headline * add row for headline to block * js clean up * linting * linting * linting css * js linting * update logic around classlist * create helix-query and start lookahead dropdown * display list w/ listeners * css tune * css clean up * js clean up * js update * js linting * css linting * css linting color notation * add communities search styles * Feat/login2 (#190) * Cherry-pick feat/login changes * Fix JS errors * Missing from merge * Missed in the merge * Adding delay logic to load the delayed part of the form * Added more utility functions around login/logout and session state tracking for logged in user * treat invalid login as a logout as well * More login handling of errors, fetching profile, and showing user name in navigation * Remove external ID from initial login * Handle logged-in user on page load * Fix lint issue * Remove console logging --------- Co-authored-by: Brendan Robert <brobert@adobe.com> * add style to adjust hero brightness in community dir * Adjusted the city text and styling * add braces * css linting * Feat/profile (#194) * Update profile code implemetation * Start of profile UI implementation and saving routine * Tabs are working, needs more form styling and validation though * Style improvements, also adjusted selectors to make linter happy * Dropdown values implemented * More styling work and visual support for required fields * Updated form placeholders to match site * Remove empty selections for regional preferences * Skip missing profile check * Improvements to error reporting and form submission. * Profile saves working! * Password reset working * Navigation and login/logout starting to work. Needs more testing * Small cleanup * Fix linting gripes * Better session handling and also moved header visibility changes into header block (and out of login-delayed) * Refactored i18n to be in utils * Add i18n support to header nav * Added i18n to login form * Add i18n to login form * Missing two text strings in i18n for login --------- Co-authored-by: Brendan Robert <brobert@adobe.com> * Intermediate work. * Intermediate work with property search bar refactor. * Final work for updating Property Search Bar. * Fix some lint. * Functional Search results list. * Functional Search results list. * Add pagination and all associated logic. * Intermediate work on map. * Intermediate info window work. * More intermediate pin work. * Finalize info window views. * Finish mapping logic. * Push first search off til after CWV * No redraw on pagination. * Trying css based animation. * Fix some minor visual bugs. --------- Co-authored-by: Rob Rusher <rrusher@adobe.com> Co-authored-by: Rob Rusher <robrusher@gmail.com> Co-authored-by: Brendan Robert <brendan.robert@gmail.com> Co-authored-by: Brendan Robert <brobert@adobe.com> --- .eslintrc.js => .eslintrc.cjs | 2 + .github/workflows/run-tests.yaml | 1 + .mocharc.json | 3 + blocks/agent-search/builders/tags.js | 1 + blocks/header/header.css | 5 +- blocks/hero/search/agent.css | 3 - blocks/hero/search/home-delayed.js | 227 --- blocks/hero/search/home.css | 8 +- blocks/hero/search/home.js | 128 +- blocks/hero/search/home/filters.js | 74 + blocks/hero/search/search.css | 19 +- blocks/login/login.js | 4 +- blocks/property-listing/map-search.js | 25 - blocks/property-listing/property-listing.css | 2 +- blocks/property-listing/property-listing.js | 121 +- blocks/property-listing/radius-search.js | 22 - blocks/property-listing/search.js | 59 - .../property-result-listing.css | 350 ---- .../property-result-listing.js | 200 --- blocks/property-result-map/Template.js | 93 -- blocks/property-result-map/map-delayed.js | 1069 ------------ blocks/property-result-map/map.css | 449 ------ blocks/property-result-map/map.js | 54 - blocks/property-search-bar/README.md | 15 + blocks/property-search-bar/common-function.js | 367 ----- blocks/property-search-bar/delayed.js | 918 +++++++++++ .../property-search-bar/filter-processor.js | 367 ----- .../additional-filter-buttons-delayed.js | 32 - .../filters/additional-filter-buttons.js | 37 - .../filters/additional-filters.js | 215 --- .../filters/additional-params-delayed.js | 162 -- .../filters/top-delayed.js | 232 --- .../property-search-bar/filters/top-menu.js | 168 -- .../property-search-bar.css | 1429 ++++++++--------- .../property-search-bar.js | 99 +- .../search-results-dropdown.css | 66 - .../property-search-bar/search/suggestion.js | 50 - blocks/property-search-results/README.md | 50 + blocks/property-search-results/loader.js | 10 + blocks/property-search-results/map.css | 562 +++++++ blocks/property-search-results/map.js | 434 +++++ .../property-search-results/map/clusters.js | 73 + blocks/property-search-results/map/drawing.js | 150 ++ blocks/property-search-results/map/pins.js | 342 ++++ blocks/property-search-results/observers.js | 87 + .../property-search-results.css | 502 ++++++ .../property-search-results.js | 264 +++ blocks/property-search-results/results.js | 102 ++ .../cards => shared/property}/cards.css | 20 +- .../cards => shared/property}/cards.js | 53 +- .../property}/luxury-collection-template.css | 11 +- .../search-countries/search-countries.js | 2 +- blocks/shared/search/suggestion.js | 103 ++ blocks/shared/search/util.js | 168 ++ icons/checkmark.svg | 4 +- icons/close-x-white.svg | 11 + icons/close-x.svg | 11 + icons/filter-white.svg | 18 + icons/globe.png | Bin 0 -> 2350 bytes icons/heartempty.svg | 2 +- icons/heartemptydark.svg | 4 +- icons/heartfull.svg | 9 + icons/maps/loader_opt.mp4 | Bin 58142 -> 0 bytes icons/maps/loader_opt.webm | Bin 33962 -> 0 bytes icons/maps/map-reveal-marker-standard.png | Bin 15748 -> 0 bytes icons/pencil.svg | 1 + icons/search.svg | 11 + package-lock.json | 463 ++++++ package.json | 9 +- scripts/apis/creg/ApplicationType.js | 25 - scripts/apis/creg/OpenHouses.js | 9 - scripts/apis/creg/PropertyType.js | 13 - scripts/apis/creg/SearchParameters.js | 155 -- scripts/apis/creg/SearchType.js | 55 - scripts/apis/creg/creg.js | 115 +- scripts/apis/creg/search/Search.js | 391 +++++ .../apis/creg/search/types/AddressSearch.js | 28 + scripts/apis/creg/search/types/BoxSearch.js | 81 + scripts/apis/creg/search/types/CitySearch.js | 28 + .../search/types/ElementarySchoolSearch.js | 10 + .../creg/search/types/HighSchoolSearch.js | 10 + scripts/apis/creg/search/types/ListingType.js | 38 + .../creg/search/types/MLSListingKeySearch.js | 38 + .../creg/search/types/MiddleSchoolSearch.js | 10 + .../creg/search/types/NeighborhoodSearch.js | 28 + scripts/apis/creg/search/types/OpenHouses.js | 39 + .../apis/creg/search/types/PolygonSearch.js | 86 + .../creg/search/types/PostalCodeSearch.js | 28 + .../apis/creg/search/types/PropertyType.js | 72 + .../apis/creg/search/types/RadiusSearch.js | 56 + .../creg/search/types/SchoolDistrictSearch.js | 28 + .../apis/creg/search/types/SchoolSearch.js | 23 + scripts/apis/creg/suggestion.js | 72 + scripts/apis/creg/workers/listing.js | 21 + scripts/apis/creg/workers/metadata.js | 35 + scripts/apis/creg/workers/properties.js | 47 + scripts/apis/creg/workers/propertySearch.js | 16 - scripts/delayed.js | 8 +- scripts/dom-helpers.js | 99 ++ scripts/scripts.js | 25 +- scripts/search.js | 44 - scripts/search/results.js | 35 - scripts/util.js | 26 + styles/images/loading.png | Bin 0 -> 31292 bytes styles/styles.css | 2 +- test/apis/creg/search/Search.test.js | 250 +++ .../creg/search/types/AddressSearch.test.js | 78 + test/apis/creg/search/types/BoxSearch.test.js | 97 ++ .../apis/creg/search/types/CitySearch.test.js | 78 + .../types/ElementarySchoolSearch.test.js | 78 + .../search/types/HighSchoolSearch.test.js | 78 + .../search/types/MLSListingKeySearch.test.js | 87 + .../search/types/MiddleSchoolSearch.test.js | 78 + .../search/types/NeighborhoodSearch.test.js | 78 + .../creg/search/types/PolygonSearch.test.js | 119 ++ .../search/types/PostalCodeSearch.test.js | 78 + .../creg/search/types/RadiusSearch.test.js | 86 + .../search/types/SchoolDistrictSearch.test.js | 78 + 118 files changed, 7925 insertions(+), 5686 deletions(-) rename .eslintrc.js => .eslintrc.cjs (84%) create mode 100644 .mocharc.json delete mode 100644 blocks/hero/search/home-delayed.js create mode 100644 blocks/hero/search/home/filters.js delete mode 100644 blocks/property-listing/map-search.js delete mode 100644 blocks/property-listing/radius-search.js delete mode 100644 blocks/property-listing/search.js delete mode 100644 blocks/property-result-listing/property-result-listing.css delete mode 100644 blocks/property-result-listing/property-result-listing.js delete mode 100644 blocks/property-result-map/Template.js delete mode 100644 blocks/property-result-map/map-delayed.js delete mode 100644 blocks/property-result-map/map.css delete mode 100644 blocks/property-result-map/map.js create mode 100644 blocks/property-search-bar/README.md delete mode 100644 blocks/property-search-bar/common-function.js create mode 100644 blocks/property-search-bar/delayed.js delete mode 100644 blocks/property-search-bar/filter-processor.js delete mode 100644 blocks/property-search-bar/filters/additional-filter-buttons-delayed.js delete mode 100644 blocks/property-search-bar/filters/additional-filter-buttons.js delete mode 100644 blocks/property-search-bar/filters/additional-filters.js delete mode 100644 blocks/property-search-bar/filters/additional-params-delayed.js delete mode 100644 blocks/property-search-bar/filters/top-delayed.js delete mode 100644 blocks/property-search-bar/filters/top-menu.js delete mode 100644 blocks/property-search-bar/search-results-dropdown.css delete mode 100644 blocks/property-search-bar/search/suggestion.js create mode 100644 blocks/property-search-results/README.md create mode 100644 blocks/property-search-results/loader.js create mode 100644 blocks/property-search-results/map.css create mode 100644 blocks/property-search-results/map.js create mode 100644 blocks/property-search-results/map/clusters.js create mode 100644 blocks/property-search-results/map/drawing.js create mode 100644 blocks/property-search-results/map/pins.js create mode 100644 blocks/property-search-results/observers.js create mode 100644 blocks/property-search-results/property-search-results.css create mode 100644 blocks/property-search-results/property-search-results.js create mode 100644 blocks/property-search-results/results.js rename blocks/{property-listing/cards => shared/property}/cards.css (96%) rename blocks/{property-listing/cards => shared/property}/cards.js (67%) rename blocks/{property-listing/cards => shared/property}/luxury-collection-template.css (71%) create mode 100644 blocks/shared/search/suggestion.js create mode 100644 blocks/shared/search/util.js create mode 100644 icons/close-x-white.svg create mode 100644 icons/close-x.svg create mode 100644 icons/filter-white.svg create mode 100644 icons/globe.png create mode 100644 icons/heartfull.svg delete mode 100644 icons/maps/loader_opt.mp4 delete mode 100644 icons/maps/loader_opt.webm delete mode 100644 icons/maps/map-reveal-marker-standard.png create mode 100644 icons/pencil.svg create mode 100644 icons/search.svg delete mode 100644 scripts/apis/creg/ApplicationType.js delete mode 100644 scripts/apis/creg/OpenHouses.js delete mode 100644 scripts/apis/creg/PropertyType.js delete mode 100644 scripts/apis/creg/SearchParameters.js delete mode 100644 scripts/apis/creg/SearchType.js create mode 100644 scripts/apis/creg/search/Search.js create mode 100644 scripts/apis/creg/search/types/AddressSearch.js create mode 100644 scripts/apis/creg/search/types/BoxSearch.js create mode 100644 scripts/apis/creg/search/types/CitySearch.js create mode 100644 scripts/apis/creg/search/types/ElementarySchoolSearch.js create mode 100644 scripts/apis/creg/search/types/HighSchoolSearch.js create mode 100644 scripts/apis/creg/search/types/ListingType.js create mode 100644 scripts/apis/creg/search/types/MLSListingKeySearch.js create mode 100644 scripts/apis/creg/search/types/MiddleSchoolSearch.js create mode 100644 scripts/apis/creg/search/types/NeighborhoodSearch.js create mode 100644 scripts/apis/creg/search/types/OpenHouses.js create mode 100644 scripts/apis/creg/search/types/PolygonSearch.js create mode 100644 scripts/apis/creg/search/types/PostalCodeSearch.js create mode 100644 scripts/apis/creg/search/types/PropertyType.js create mode 100644 scripts/apis/creg/search/types/RadiusSearch.js create mode 100644 scripts/apis/creg/search/types/SchoolDistrictSearch.js create mode 100644 scripts/apis/creg/search/types/SchoolSearch.js create mode 100644 scripts/apis/creg/suggestion.js create mode 100644 scripts/apis/creg/workers/listing.js create mode 100644 scripts/apis/creg/workers/metadata.js create mode 100644 scripts/apis/creg/workers/properties.js delete mode 100644 scripts/apis/creg/workers/propertySearch.js create mode 100644 scripts/dom-helpers.js delete mode 100644 scripts/search.js delete mode 100644 scripts/search/results.js create mode 100644 styles/images/loading.png create mode 100644 test/apis/creg/search/Search.test.js create mode 100644 test/apis/creg/search/types/AddressSearch.test.js create mode 100644 test/apis/creg/search/types/BoxSearch.test.js create mode 100644 test/apis/creg/search/types/CitySearch.test.js create mode 100644 test/apis/creg/search/types/ElementarySchoolSearch.test.js create mode 100644 test/apis/creg/search/types/HighSchoolSearch.test.js create mode 100644 test/apis/creg/search/types/MLSListingKeySearch.test.js create mode 100644 test/apis/creg/search/types/MiddleSchoolSearch.test.js create mode 100644 test/apis/creg/search/types/NeighborhoodSearch.test.js create mode 100644 test/apis/creg/search/types/PolygonSearch.test.js create mode 100644 test/apis/creg/search/types/PostalCodeSearch.test.js create mode 100644 test/apis/creg/search/types/RadiusSearch.test.js create mode 100644 test/apis/creg/search/types/SchoolDistrictSearch.test.js 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 = ` - <select name="${name}" aria-label="${placeholder}"> - <option value="">Bedrooms</option> - </select> - <div class="selected" role="button" aria-haspopup="listbox" aria-label="${placeholder}">${placeholder}</div> - <ul class="select-items" role="listbox"> - <li role="option">${placeholder}</li> - </ul> - `; +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 = ` <div class="mobile-header"> @@ -97,15 +119,16 @@ async function buildForm() { <div class="filters"> <input type="text" placeholder="$ Minimum Price" name="MinPrice" aria-label="minimum price"> <input type="text" placeholder="$ Maximum Price" name="MaxPrice" aria-label="maximum price"> - ${buildSelect('MinBedroomsTotal', 'Bedrooms', 12).outerHTML} - ${buildSelect('MinBathroomsTotal', 'Bathrooms', 8).outerHTML} + ${buildFilterSelect('MinBedroomsTotal', 'Bedrooms', BED_BATHS).outerHTML} + ${buildFilterSelect('MinBathroomsTotal', 'Bathrooms', BED_BATHS).outerHTML} </div> <button class="submit" type="submit">Search</button> -`; + `; + + 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 = ` <div class="login-overlay"></div> <div class="login-form"> @@ -139,6 +139,8 @@ export default async function decorate(block) { </div> </div> `; + /* 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 = ` - <div class="search-results-loader-image enter"> - <video autoplay loop muted playsinline> - <source src="/icons/maps/loader_opt.webm" type="video/webm" /> - <source src="/icons/maps/loader_opt.mp4" type="video/mp4" /> - </video> - </div> - `; - 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 = ` - <section> - <a rel="noopener" target="_blank" tabIndex="" class="btn btn-map" role="button"> - <span class="text-up">Save</span> - </a> - </section> - <section> - <a rel="noopener" target="_blank" tabIndex="" class="btn btn-map map" role="button"> - <span class="text-up">list view</span> - </a> - </section> - `; - return wrapper; -} - -function buildDisclaimer(html) { - const wrapper = document.createElement('div'); - wrapper.classList.add('disclaimer'); - wrapper.innerHTML = ` - <hr role="presentation" aria-hidden="true" tabindex="-1"> - <div class="text"> - ${html} - </div> - `; - 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 += `<option value="${i}">${i}</option>`; - } - for (let i = 1; i <= totalPages; i += 1) { - list += `<li data-value="${i}" role="option">${i}</li>`; - } - wrapper.innerHTML = ` - <div class="search-results-dropdown"> - <div class="select-wrapper"> - <select class ="hide" aria-label="${`${currentPage} of ${totalPages}`}">${options}</select> - <div class="select-selected text-up" role="button" aria-haspopup="listbox"> - ${`${currentPage} of ${totalPages}`} - </div> - <ul class="select-item hide" role="listbox"> - ${list} - </ul> - </div> - </div> - <div class="pagination-arrows"> - <a class="prev arrow ${currentPage === 1 && 'disabled'}" role="button" aria-label="Previous Page"></a> - <a class="next arrow ${currentPage === totalPages && 'disabled'}" role="button" aria-label="Next Page"></a> - </div>`; - 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"> - <div class="cmp-property-tile__image-labels"> - <span class="cmp-property-tile__label--luxury text-uppercase">${data.luxuryLabel}</span> - </div> - </div>` - : ''; - const soldHTML = data.sellingOfficeName ? `<div class="text-danger">${data.mlsStatus} ${data.ClosedDate}<br/></div>` : ''; - const municipalityHTML = data.municipality ? `<div class="address">${data.municipality}</div>` : ''; - const CourtesyOfHr = data.CourtesyOf ? ` - <hr style="margin-top: 0px; margin-bottom: 0px;">` : ''; - const addMlsFlagHTML = data.addMlsFlag ? `<span class="cmp-property-tile__extra-info" style="padding-left: 0px">MLS ID: ${data.ListingId} </span>` : ''; - const courtersyHTML = data.CourtesyOf ? ` - <span class="cmp-property-tile__extra-info" style="padding-left: 0px">Listing courtesy of: ${data.CourtesyOf} </span>` : ''; - const sellingOfficeNameHTML = data.sellingOfficeName ? ` - <span class="cmp-property-tile__extra-info" style="padding-left: 0px">Listing sold by: ${data.sellingOfficeName} </span>` : ''; - const addMLsFlagHTML = data.addMlsFlag ? ` - <div class="cmp-property-tile__extra-info d-flex align-items-center justify-content-between" style="padding: 0px; margin-top: -5px;"><div>Listing Provided by: ${data.listAor}</div> - ${data.listAor} - ${data.brImageUrl} - <div class="cmp-property-search-results__disclaimer__logo" > - <div style="background-image:url(${data.brImageUrl}); background-size: contain; - background-repeat: no-repeat; - width: 60px; - height: 30px;"></div> - ${data.ImageUrl} - </div> - </div> - ${data.ddMlsFlag}` : ''; - return `<div class="info-window"> - <a href="${data.linkUrl}" rel="noopener"> - <div class="property-image" style="background-image:url(${data.image}); background-size:cover;"> - ${luxuryHTML} - </div> - <div class="info" style="padding: 5px"> - <div class="d-flex align-items-center p-0" style="flex-wrap:wrap;"> - <div class="price">${data.price}</div><br/> - <div class="altPrice">${data.altCurrencyPrice || ''}</div> - <div class="btn-contact-property p-0 mr-2" - data-brand="undefined" - data-lead-param="CompanyKey=CA321&LeadBrand=11413101001000010000" - data-pdp-path="https://www.bhhsfranciscan.com/ca/224-sea-cliff-avenue-san-francisco-94121/pid-2217354422?lead=CompanyKey%3DCA321%26LeadBrand%3D11413101001000010000" - data-prop-id="${data.propertyId}" - data-street-name="${data.address}" - data-city="${data.city}" - data-state-or-province="${data.stateOrProvince}" - data-postal-code="${data.postalCode}" - data-providers="${data.providers || ''}" - > - <svg class="envelope"> - <use xlink:href="/icons/icons.svg#envelope"></use> - </svg> - <svg class="envelope-dark"> - <use xlink:href="/icons/icons.svg#envelope-dark"></use> - </svg> - </div> - <div class="btn-save-property p-0 mr-2" data-prop-id="${data.propertyId}" data-street-name="${data.address}"> - <svg class="empty"> - <use xlink:href="/icons/icons.svg#heart-empty"></use> - </svg> - <svg class="empty-dark"> - <use xlink:href="/icons/icons.svg#heart-empty-dark"></use> - </svg> - <svg class="full"> - <use xlink:href="/icons/icons.svg#heart-full"></use> - </svg> - </div> - </div> - <div class="address"> - ${soldHTML} - <div> - ${data.address || ''}<br/> - ${data.city || ''}, ${data.stateOrProvince || ''} ${data.postalCode || ''} - </div> - </div> - ${municipalityHTML} - <div class="providers">${data.providers || ''}</div> - ${CourtesyOfHr} - <div> - ${addMlsFlagHTML} - ${courtersyHTML} - ${sellingOfficeNameHTML} - </div> - ${addMLsFlagHTML} - </div> - </a> - <div class="arrow"></div> -</div>`; - }; -} 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,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"); - 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 = `<div class="custom-controls"> - <a data-text="Satellite" data-text-map="Map" class="map-style-hybrid" role="button" aria-label="Satellite View"></a> - <a data-text="Draw" data-text-close="Complete Draw" class="map-draw-complete d-none" role="button" aria-label="Complete Draw"></a> - <a data-text="Draw" data-text-close="Close" class="map-draw" role="button" aria-label="Close"></a> - <div class="zoom-controls"> - <a class="map-zoom-in" role="button" aria-label="Zoom In"></a> - <a class="map-zoom-out" role="button" aria-label="Zoom Out"></a> - </div> - </div> - <div class="map-draw-tooltip d-none"> - Click points on the map to draw your search - </div> - <div class="map-search-wrapper"> - <a data-text-add="Add map boundary" data-text-remove="Remove map boundary" class="map-search-toggle" role="button" aria-label="Remove Map Boundary"></a> - </div> - `; - 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 = `<option value="">${defaultValue}</option>`; - if (Array.isArray(conf)) { - conf.forEach((el) => { - output += `<option value="${el.value}">${el.label}</option>`; - }); - } else { - const labelSuf = `+ ${defaultValue.split(' ')[1]}`; - for (let i = 1; i <= conf; i += 1) { - const label = `${i} ${labelSuf}`; - output += `<option value="${i}">${label}</option>`; - } - } - return output; -} - -/** - * @param {string} filterName - * @param {string} defaultValue - * @returns {string} - */ -function buildListBoxOptions(filterName, defaultValue) { - const config = getConfig(filterName); - let output = `<li data-value="" class="tooltip-container highlighted">${defaultValue}</li>`; - if (Array.isArray(config)) { - config.forEach((conf) => { - output += `<li data-value="${conf.value}" class="tooltip-container">${conf.label}</li>`; - }); - } else { - const labelSuf = `+ ${defaultValue.split(' ')[1]}`; - for (let i = 1; i <= config; i += 1) { - const label = `${i} ${labelSuf}`; - output += `<li data-value="${i}" class="tooltip-container">${label}</li>`; - } - } - - return output; -} - -export function addOptions(filterName, defaultValue = '', mode = '', name = '') { - let output = `<section> - <div> - <select class="hide" aria-label="${defaultValue}">${buildSelectOptions(filterName, defaultValue, mode)}</select>`; - if (mode === 'multi') { - output += `<div class="select-selected" role="button" aria-haspopup="listbox" name=${name}>${defaultValue}</div>`; - } - output += `<ul class="select-item" role="listbox">${buildListBoxOptions(filterName, defaultValue, mode)}</ul> - </div> - </section>`; - 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 = `<div class="multiple-inputs"> - <div id="Min${filterLabel}" class="input-dropdown"> - <input type="text" maxLength="${maxLength}" list="listMin${filterLabel}" - name="Min${filterLabel}" aria-describedby="Min${filterLabel}" - placeholder="${fromLabel}" aria-label="Minimum ${filterLabel}" - class="price-range-input min-price"> - <datalist id="listMinPrice" class="list${filterLabel}"></datalist> - </div> - <span class="range-label text-up">to</span> - <div id="Max${filterLabel}" class="input-dropdown"> - <input type="text" maxLength="${maxLength}" list="listMax${filterLabel}" name="Max${filterLabel}" aria-describedby="Max${filterLabel}" - placeholder="${toLabel}" aria-label="Maximum ${filterLabel}" - class="price-range-input max-price"> - <datalist id="listMax${filterLabel}" class="list${filterLabel}"></datalist> - </div> - </div>`; - } - if (filterName === 'LivingArea' || filterName === 'YearBuilt') { - const fromName = filterName === 'LivingArea' ? 'MinLivingArea' : ''; - const toName = filterName === 'LivingArea' ? 'MaxLivingArea' : ''; - output = `<div class="multiple-inputs"> - ${addOptions(filterName, fromLabel, 'multi', fromName)} - <span class="range-label text-up">to</span> - ${addOptions(filterName, toLabel, 'multi', toName)} - </section> - </div> - `; - } - 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 = ` - <input hidden="hidden" type="checkbox" aria-label="Hidden checkbox" value="${value.toLowerCase() === defaultInput}"> - <div class="checkbox ${value.toLowerCase() === defaultInput ? 'checked' : ''}"></div> - <label role="presentation" class="ml-1">${value}</label> - </div>`; - 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 = '<div class="column-2 flex-row">'; - - columns.forEach((column) => { - output += '<div class="column">'; - column.forEach((value) => { - el = processSearchType(value); - el.querySelector('label').classList.add('text-up'); - output += el.outerHTML; - }); - output += '</div>'; - }); - output += '</div>'; - 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 += `<option> ${d * k[m - 1]} </option>`; + 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)} -<br/>$${abbrNum(high, 2)}`; + } else if (low !== '') { + content = `$${abbrNum(low, 2)}`; + } else if (high !== '') { + content = `$0 -<br/>$${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} -<br/>${max.textContent}`; + } + wrapper.querySelector('span').innerHTML = label; +} + +function addKeyword(wrapper, value) { + const keyword = document.createElement('div'); + keyword.classList.add('keyword'); + keyword.innerHTML = ` + <span>${value}</span> + <span class="close">X</span> + `; + 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 = ` + <div class="listing-types"> + <label class="section-label">Search Types</label> + <div class="filter-toggle"> + <input name="${ListingType.FOR_SALE.type}" hidden="hidden" type="checkbox" aria-label="Hidden checkbox" checked="checked" value="${ListingType.FOR_SALE.type}"> + <div class="checkbox checked"></div> + <label role="presentation">${ListingType.FOR_SALE.label}</label> + </div> + <div class="filter-toggle"> + <input name="${ListingType.FOR_RENT.type}" hidden="hidden" type="checkbox" aria-label="Hidden checkbox" value="${ListingType.FOR_RENT.type}"> + <div class="checkbox"></div> + <label role="presentation">${ListingType.FOR_RENT.label}</label> + </div> + <div class="filter-toggle"> + <input name="${ListingType.PENDING.type}" hidden="hidden" type="checkbox" aria-label="Hidden checkbox" value="${ListingType.PENDING.type}"> + <div class="checkbox"></div> + <label role="presentation">${ListingType.PENDING.label}</label> + </div> + <div class="filter-toggle"> + <input name="${ListingType.RECENTLY_SOLD.type}" hidden="hidden" type="checkbox" aria-label="Hidden checkbox" value="${ListingType.RECENTLY_SOLD.type}"> + <div class="checkbox"></div> + <label role="presentation">${ListingType.RECENTLY_SOLD.label}</label> + </div> + </div> + <div class="attributes"> + <div class="range-wrapper price"> + <label class="section-label" role="presentation">Price</label> + <div class="range-items"> + <div id="adv-min-price" class="input-dropdown"> + <input type="text" name="adv-min-price" maxlength="14" aria-describedby="adv-min-price" aria-label="Minimum price" placeholder="No Min" list="adv-list-min-price"> + <datalist id="adv-list-min-price"></datalist> + </div> + <span>to</span> + <div id="adv-max-price" class="input-dropdown"> + <input type="text" name="adv-max-price" maxlength="14" aria-describedby="adv-max-price" aria-label="Maximum price" placeholder="No Max" list="adv-list-max-price"> + <datalist id="adv-list-max-price"></datalist> + </div> + </div> + </div> + <div class="bedrooms"> + <label class="section-label" role="presentation">Bedrooms</label> + <ul> + <li data-value=""><input name="bedrooms" aria-describedby="bedrooms-any" type="radio" id="bedrooms-any" value="" tabindex="0" checked><label for="bedrooms-any">Any</label></li> + <li data-value="1"><input name="bedrooms" aria-describedby="bedrooms-1" type="radio" id="bedrooms-1" value="1" tabindex="0"><label for="bedrooms-1">1+</label></li> + <li data-value="2"><input name="bedrooms" aria-describedby="bedrooms-2" type="radio" id="bedrooms-2" value="2" tabindex="0"><label for="bedrooms-2">2+</label></li> + <li data-value="3"><input name="bedrooms" aria-describedby="bedrooms-3" type="radio" id="bedrooms-3" value="3" tabindex="0"><label for="bedrooms-3">3+</label></li> + <li data-value="4"><input name="bedrooms" aria-describedby="bedrooms-4" type="radio" id="bedrooms-4" value="4" tabindex="0"><label for="bedrooms-4">4+</label></li> + <li data-value="5"><input name="bedrooms" aria-describedby="bedrooms-5" type="radio" id="bedrooms-5" value="5" tabindex="0"><label for="bedrooms-5">5+</label></li> + </ul> + </div> + <div class="bathrooms"> + <label class="section-label" role="presentation">Bathroom</label> + <ul> + <li data-value=""><input name="bathrooms" aria-describedby="bathrooms-any" type="radio" id="bathrooms-any" value="" tabindex="0" checked><label for="bathrooms-any">Any</label></li> + <li data-value="1"><input name="bathrooms" aria-describedby="bathrooms-1" type="radio" id="bathrooms-1" value="1" tabindex="0"><label for="bathrooms-1">1+</label></li> + <li data-value="2"><input name="bathrooms" aria-describedby="bathrooms-2" type="radio" id="bathrooms-2" value="2" tabindex="0"><label for="bathrooms-2">2+</label></li> + <li data-value="3"><input name="bathrooms" aria-describedby="bathrooms-3" type="radio" id="bathrooms-3" value="3" tabindex="0"><label for="bathrooms-3">3+</label></li> + <li data-value="4"><input name="bathrooms" aria-describedby="bathrooms-4" type="radio" id="bathrooms-4" value="4" tabindex="0"><label for="bathrooms-4">4+</label></li> + <li data-value="5"><input name="bathrooms" aria-describedby="bathrooms-5" type="radio" id="bathrooms-5" value="5" tabindex="0"><label for="bathrooms-5">5+</label></li> + </ul> + </div> + <div class="sqft"> + <label class="section-label" role="presentation">Square Feet</label> + <div class="range-items"> + <div id="adv-min-sqft" class="select-wrapper"> + <select name="adv-min-sqft" aria-label="No Min"> + <option value="">No Min</option> + </select> + <div class="selected" role="combobox" aria-haspopup="listbox" aria-label="No Min" aria-expanded="false" aria-controls="adv-list-min-sqft" tabindex="0"><span>No Min</span></div> + <ul id="adv-list-min-sqft" class="select-items" role="listbox"> + <li data-value="" role="option" class="selected">No Min</li> + </ul> + </div> + <span>to</span> + <div id="adv-max-sqft" class="select-wrapper"> + <select name="adv-max-sqft" aria-label="No Max"> + <option value="">No Max</option> + </select> + <div class="selected" role="combobox" aria-haspopup="listbox" aria-label="No Max" aria-expanded="false" aria-controls="adv-list-max-sqft" tabindex="0"><span>No Max</span></div> + <ul id="adv-list-max-sqft" class="select-items" role="listbox"> + <li data-value="" role="option" class="selected">No Max</li> + </ul> + </div> + </div> + </div> + </div> + <div class="property-types"> + <label class="section-label" role="presentation">Property Type</label> + <div class="options"> + <button name="CONDO_TOWNHOUSE" type="button" class="selected" tabindex="0"> + <svg role="presentation" aria-hidden="true" tabindex="-1"><use xlink:href="${window.hlx.codeBasePath}/icons/icons.svg#condo-townhouse"></use></svg> + <span>Condo / Townhouse</span> + </button> + <button name="SINGLE_FAMILY" type="button" class="selected" tabindex="0"> + <svg role="presentation" aria-hidden="true" tabindex="-1"><use xlink:href="${window.hlx.codeBasePath}/icons/icons.svg#single-family"></use></svg> + <span>Single Family</span> + </button> + <button name="COMMERCIAL" type="button" class="" tabindex="0"> + <svg role="presentation" aria-hidden="true" tabindex="-1"><use xlink:href="${window.hlx.codeBasePath}/icons/icons.svg#commercial"></use></svg> + <span>Commercial</span> + </button> + <button name="MULTI_FAMILY" type="button" class="selected" tabindex="0"> + <svg role="presentation" aria-hidden="true" tabindex="-1"><use xlink:href="${window.hlx.codeBasePath}/icons/icons.svg#multi-family"></use></svg> + <span>Multi Family</span> + </button> + <button name="LAND" type="button" class="selected" tabindex="0"> + <svg role="presentation" aria-hidden="true" tabindex="-1"><use xlink:href="${window.hlx.codeBasePath}/icons/icons.svg#lot-land"></use></svg> + <span>Lot / Land</span> + </button> + <button name="FARM" type="button" class="selected" tabindex="0"> + <svg role="presentation" aria-hidden="true" tabindex="-1"><use xlink:href="${window.hlx.codeBasePath}/icons/icons.svg#farm-ranch"></use></svg> + <span>Farm / Ranch</span> + </button> + </div> + <div class="all"> + <label for="select-all-property-types"> + <input type="checkbox" value="" id="select-all-property-types" tabindex="0"> + <div class="checkbox"> + <svg class="empty" role="presentation" aria-hidden="true" tabindex="-1"><use xlink:href="${window.hlx.codeBasePath}/icons/icons.svg#checkmark"></use></svg> + </div> + <span class="label">Select All</span> + </label> + </div> + </div> + <div class="keywords"> + <label class="section-label" role="presentation">Keyword Search</label> + <div class="keywords-input"> + <input name="keywords" type="text" placeholder="Pool, Offices, Fireplace..." aria-label="Pool, Offices, Fireplace..."> + <button><span>Add</span></button> + </div> + <div class="keywords-list"> + + </div> + <div class="keywords-match"> + <label role="presentation">Match</label> + <label role="presenation"> + <input type="radio" name="matchType" value="any"> + <div class="radio-button"></div> + <span>Any</span> + </label> + <label role="presenation"> + <input type="radio" name="matchType" value="all" checked="checked"> + <div class="radio-button"></div> + <span>All</span> + </label> + </div> + </div> + <div class="misc"> + <div class="year-range"> + <label class="section-label" role="presentation">Year Built</label> + <div class="range-items"> + <div id="min-year" class="select-wrapper"> + <select name="min-year" aria-label="No Min"> + <option value="">No Min</option> + </select> + <div class="selected" role="combobox" aria-haspopup="listbox" aria-label="No Min" aria-expanded="false" aria-controls="list-min-year" tabindex="0"><span>No Min</span></div> + <ul id="list-min-year" class="select-items" role="listbox"> + <li data-value="" role="option" class="selected">No Min</li> + </ul> + </div> + <span>to</span> + <div id="max-year" class="select-wrapper"> + <select name="max-year" aria-label="No Max"> + <option value="">No Max</option> + </select> + <div class="selected" role="combobox" aria-haspopup="listbox" aria-label="No Max" aria-expanded="false" aria-controls="list-max-year" tabindex="0"><span>No Max</span></div> + <ul id="list-max-year" class="select-items" role="listbox"> + <li data-value="" role="option" class="selected">No Max</li> + </ul> + </div> + </div> + </div> + <hr> + <div class="is-new"> + <div class="filter-toggle"> + <label role="presentation">New Listings</label> + <input name="is-new" hidden="hidden" type="checkbox" aria-label="Hidden checkbox" value="true"> + <div class="checkbox"></div> + </div> + </div> + <hr> + <div class="price-change"> + <div class="filter-toggle"> + <label role="presentation">Recent Price Changes</label> + <input name="price-change" hidden="hidden" type="checkbox" aria-label="Hidden checkbox" value="true"> + <div class="checkbox"></div> + </div> + </div> + <hr> + <div class="open-houses"> + <div class="filter-toggle"> + <label role="presentation">Open Houses Only</label> + <input name="open-houses" hidden="hidden" type="checkbox" aria-label="Hidden checkbox" value="true"> + <div class="checkbox"></div> + </div> + <div class="open-houses-timeframe"> + <label role="presenation"> + <input type="radio" name="open-houses-timeframe" value="ONLY_WEEKEND" checked="checked"> + <div class="radio-button"></div> + <span>This Weekend</span> + </label> + <label role="presenation"> + <input type="radio" name="open-houses-timeframe" value="ANYTIME"> + <div class="radio-button"></div> + <span>Anytime</span> + </label> + </div> + </div> + <hr> + <div class="lux"> + <div class="filter-toggle"> + <label role="presentation">Luxury</label> + <input name="lux" hidden="hidden" type="checkbox" aria-label="Hidden checkbox" value="true"> + <div class="checkbox"></div> + </div> + </div> + <hr> + <div class="bhhs-only"> + <div class="filter-toggle"> + <label role="presentation">Berkshire Hathaway Home Services Listings Only</label> + <input name="bhhs-only" hidden="hidden" type="checkbox" aria-label="Hidden checkbox" value="true"> + <div class="checkbox"></div> + </div> + </div> + </div> + <div class="buttons"> + <p class="button-container"> + <a id="search-apply" href="#" title="Apply">Apply</a> + </p> + <p class="button-container secondary"> + <a id="search-cancel" href="#" title="Cancel">Cancel</a> + </p> + <p class="button-container secondary"> + <a id="search-reset" href="#" title="Reset">Reset</a> + </p> + </div> + `; + + 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 = ` - <a title="apply" rel="noopener" target="_blank" tabindex="" class="btn btn-primary center" role="button"> - <span class="text-up btn-primary c-w">apply</span> - </a>`; - buttons.forEach((button) => { - output += ` - <a title="${button}" rel="noopener" target="_blank" tabindex="" class="btn btn-secondary center" role="button"> - <span class="text-up">${button}</span> - </a>`; - }); - 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 += `<button type="button" class="flex-row" value=${property.ID}> - <svg role="presentation"> - <use xlink:href="/icons/icons.svg#${formatInput(property.Label)}"></use> - </svg> - <span class="ml-1">${property.Label}</span> - </button>`; - }); - return output; -} - -function buildCheckBox(ariaLabel, label = '') { - return `<div class=" filter-checkbox mt-1"> - <label role="presentation" class="flex-row mb-1"> - <input type="checkbox" aria-label="${ariaLabel}"> - <div class="checkbox"> - <svg role="presentation"> - <use xlink:href="/icons/icons.svg#checkmark"></use> - </svg> - </div> - <span class="label">${label}</span> - </label> - </div>`; -} - -function buildPropertyFilterHtml(label) { - const firstColumnValues = [ - PropertyType.CONDO_TOWNHOUSE, - PropertyType.COMMERCIAL, - PropertyType.LAND, - ]; - const secondColumnValues = [ - PropertyType.SINGLE_FAMILY, - PropertyType.MULTI_FAMILY, - PropertyType.FARM, - ]; - return ` - <div class="column-2 flex-row"> - <div class="column">${buildPropertyColumn(firstColumnValues)}</div> - <div class="column">${buildPropertyColumn(secondColumnValues)}</div> - </div> - ${buildCheckBox(label, 'Select All')} -`; -} - -function buildFilterOpenHouses() { - return ` - <div class="flex-row vertical-center"> - ${buildCheckBox('Open Houses Only')} - <div class="ml-1 mr-1"> - <label role="presentation" class="flex-row center"> - <input type="radio" value=${OpenHouses.ONLY_WEEKEND.value}> - <div class="radio-btn"></div> - <span class="">${OpenHouses.ONLY_WEEKEND.label}</span> - </label> - </div> - <div> - <label role="presentation" class="flex-row vertical-center"> - <input type="radio" value=${OpenHouses.ANYTIME.value}> - <div class="radio-btn"></div> - <span class="">${OpenHouses.ANYTIME.label}</span> - </label> - </div> - </div> -`; -} -function buildKeywordSearch() { - return ` - <div class="flex-row vertical-center container-input"> - <input type="text" placeholder="Pool, Offices, Fireplace..." aria-label="Pool, Offices, Fireplace..."> - <button type="submit" class="btn secondary center"> - <span class="text-up">add</span> - </button> - </div> - <div id="container-tags" class="mt-1"></div> - <br> - <div class="flex-row vertical-center"> - <label class="text-up vertical-center" role="presentation">match</label> - <div class="filter-radiobutton"> - <label role="presentation" class="flex-row vertical-center ml-1 mr-1"> - <input type="radio" name="matchTagsAny" value="false"> - <div class="radio-btn"></div> - <span class="fs-1">Any</span> - </label> - </div> - <div class="filter-radiobutton"> - <label role="presentation" class="flex-row vertical-center"> - <input type="radio" name="matchTagsAll" value="true"> - <div class="radio-btn"></div> - <span class="fs-1">All</span> - </label> - </div> - </div> -</div>`; -} - -function buildFilterToggle() { - return ` - <div> - <div class="filter-toggle"> - <input hidden="hidden" type="checkbox" aria-label="Hidden checkbox" value="true"> - <div class="checkbox"></div> - </div> - </div>`; -} - -function buildSectionFilter(filterName) { - const number = getConfig(filterName); - const defaultValue = 'Any'; - const name = filterName.toLowerCase(); - let output = ` - <ul class="flex-row tile"> - <li> - <input aria-describedby="${name}${defaultValue}" type="radio" id="${name}${defaultValue}" value=${defaultValue}> - <label for="${name}${defaultValue}">${defaultValue}</label> - </li>`; - - for (let i = 1; i <= number; i += 1) { - output += `<li> - <input aria-describedby="${name}${i}" type="radio" id="${name}${i}" value=${i}> - <label for="${name}${i}">${`${i}+`}</label> - </li>`; - } - - output += '</ul>'; - 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 = ` <label class="section-label text-up">${label}</label> - ${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 = `<span>${selectedElValue}</span>`; - } - 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 += `<option> ${d * k[m - 1]} </option>`; - 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 = `<span>${selectedElValue}</span>`; - 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 = '<div role="heading" aria-level="1"/>'; - return wrapper; -} - -function buildMapToggle() { - const wrapper = document.createElement('div'); - wrapper.classList.add('map-toggle', 'flex-row', 'center'); - wrapper.innerHTML = ` - <a rel="noopener" target="_blank" tabindex="" class="btn btn-map-toggle" role="button"> - <span class="text-up"> - grid view - </span></a>`; - return wrapper; -} - -function buildButton(label, primary = false) { - const button = document.createElement('div'); - button.classList.add('button-container'); - button.innerHTML = ` - <a target="_blank" tabindex="" class="btn center ${primary ? 'btn-primary' : 'btn-secondary'}" role="button"> - <span>${label}</span> - </a>`; - return button; -} - -function buildFilterToggle() { - const wrapper = document.createElement('div'); - wrapper.setAttribute('name', 'AdditionalFilters'); - wrapper.classList.add('filter-container', 'flex-row', 'center', 'bl'); - wrapper.innerHTML = ` - <a role="button" aria-label="Filter"> - <svg role="presentation"> - <use xlink:href="/icons/icons.svg#filter-white"></use> - </svg> - <svg role="presentation" class="hide"> - <use xlink:href="/icons/icons.svg#close-x-white"></use></svg> - </a>`; - 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 = `<div class="header"> - <div class="title mr-1"><span>${label}</span></div> - </div> - <div class="search-results-dropdown">${options}</div>`; - 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 = `<div class="header"> - <div class="title text-up"><span>${label}</span></div> - </div> - <div class="search-results-dropdown input hide shadow">${options}</div>`; - - 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 = ` <div class="search-bar search-bar" role="search"> - <div class="search-suggester suggester-input"> - <input type="text" placeholder="${getPlaceholder('US')}" aria-label="${getPlaceholder('US')}" name="keyword"> - <input type="hidden" name="query"> - <input type="hidden" name="type"> - <ul class="suggester-results"> - <li class="list-title">Please enter at least 3 characters.</li> - </ul> - </div> - </div>`; - 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 = ` + <form action="/search"> + <div class="search-bar" role="search"> + <div class="search-suggester suggester-input"> + <input type="text" placeholder="${getPlaceholder()}" aria-label="${getPlaceholder()}" name="keyword"> + <input type="hidden" name="query"> + <input type="hidden" name="type"> + <ul class="suggester-results"> + <li class="list-title">Please enter at least 3 characters.</li> + </ul> + </div> + </div> + <div class="result-filters"> + ${buildDataListRange('price', 'Price').outerHTML} + ${buildFilterSelect('bedrooms', 'Beds', BED_BATHS).outerHTML} + ${buildFilterSelect('bathrooms', 'Baths', BED_BATHS).outerHTML} + ${buildSelectRange('sqft', 'Square Feet', SQUARE_FEET).outerHTML} + <a class="filter" type="button" aria-label="More Filters" aria-haspopup="true"> + <span class="icon icon-filter-white"></span> + <span class="icon icon-close-x-white"></span> + </a> + <a class="save-search" type="button" aria-label="Save Search" role="button"><span>Save Search</span></a> + </div> + <a href="#" class="search-submit" aria-label="Search"> + <span class="icon icon-search"></span> + </a> + <div class="advanced-filters"> + </div> + <div class="search-overlay"></div> + </form> + `; + 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<void>} + */ +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<google.map.Polyline>} 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<Array<HTMLElement>>} 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<void>} + */ +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 = '<svg><use xlink:href="/icons/icons.svg#carrot"/></svg>'; + const next = a({ + class: 'next', + 'aria-label': 'Next Page', + role: 'button', + 'data-value': `${current + 1}`, + }); + next.innerHTML = '<svg><use xlink:href="/icons/icons.svg#carrot"/></svg>'; + + 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 `<img src="${listing.SmallMedia[0].mediaUrl}" alt="Property Image" loading="lazy" class="property-thumbnail">`; + return `<img src="${listing.SmallMedia[0].mediaUrl}" alt="${listing.StreetName}" loading="lazy" class="property-thumbnail">`; } return '<div class="property-no-available-image"><span>no images available</span></div>'; } @@ -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' ? `<span class="property-label new-listing">${listing.ApplicationType}</span>` : ''; + const applicationType = listing.ListingType && listing.ListingType === 'For Rent' ? `<span class="property-label new-listing">${listing.ListingType}</span>` : ''; if (listing.ClosedDate !== '01/01/0001') { item.classList.add('is-sold'); @@ -49,7 +46,7 @@ export function createCard(listing) { } item.innerHTML = ` - <a href="${detailsPath}" rel="noopener" aria-label="${listing.StreetName}"> + <a href="${detailsPath}" rel="noopener" aria-labelledby="listing-${listing.ListingId}-address"> <div class="listing-image-container"> <div class="property-image"> ${createImage(listing)} @@ -70,7 +67,7 @@ export function createCard(listing) { <span class="property-label">${listing.mlsStatus}</span> </div> <div class="property-price"> - ${listing.ListPriceUS} + <p>${listing.ListPriceUS}</p> </div> </div> </div> @@ -79,7 +76,7 @@ export function createCard(listing) { <div class="property-info-wrapper"> <div class="property-info"> <div class="sold-date">Closed: ${listing.ClosedDate}</div> - <div class="address"> + <div id="listing-${listing.ListingId}-address" class="address"> ${listing.StreetName} <br> ${listing.City}, ${listing.StateOrProvince} ${listing.PostalCode} @@ -89,13 +86,21 @@ export function createCard(listing) { </div> <div class="property-buttons"> <div class="buttons-row-flex"> - <a aria-label="Contact Form" href="#" class="button-property"> - <span class="icon icon-envelope"></span> - <span class="icon icon-envelopedark"></span> + <a aria-label="Contact us about ${listing.StreetName}" href="#" class="button-property"> + <span class="icon icon-envelope"> + <img data-icon-name="envelope" src="/icons/envelope.svg" loading="lazy" alt="envelope"> + </span> + <span class="icon icon-envelopedark"> + <img data-icon-name="envelopedark" src="/icons/envelopedark.svg" loading="lazy" alt="envelope"> + </span> </a> - <a aria-label="Save" href="#" class="button-property"> - <span class="icon icon-heartempty"></span> - <span class="icon icon-heartemptydark"></span> + <a aria-label="Save ${listing.StreetName} to saved properties." href="#" class="button-property"> + <span class="icon icon-heartempty"> + <img data-icon-name="heartempty" src="/icons/heartempty.svg" loading="lazy" alt="heart"> + </span> + <span class="icon icon-heartemptydark"> + <img data-icon-name="heartempty" src="/icons/heartemptydark.svg" loading="lazy" alt="heart"> + </span> </a> </div> </div> @@ -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<void>} + * @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 = ` + <select name="${name}" aria-label="${placeholder}"> + <option value="">Any ${placeholder}</option> + </select> + <div class="selected" role="button" aria-haspopup="listbox" aria-label="${placeholder}" aria-expanded="false" tabindex="0"><span>Any ${placeholder}</span></div> + <ul class="select-items" role="listbox"> + <li role="option" class="selected" data-value="">Any ${placeholder}</li> + </ul> + `; + + 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 = ` + <div class="selected" role="button" aria-haspopup="listbox" aria-label="${placeholder}" aria-expanded="false" tabindex="0"><span>${placeholder}</span></div> + <div class="range-items"> + <div id="min-${name}" class="input-dropdown"> + <input type="text" name="min-${name}" maxlength="14" aria-describedby="min-${name}" aria-label="Minimum ${name}" placeholder="No Min" list="list-min-${name}"> + <datalist id="list-min-${name}"></datalist> + </div> + <span>to</span> + <div id="max-${name}" class="input-dropdown"> + <input type="text" name="max-${name}" maxlength="14" aria-describedby="max-${name}" aria-label="Maximum ${name}" placeholder="No Max" list="list-max-${name}"> + <datalist id="list-max-${name}"></datalist> + </div> + </div> + `; + 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 = ` + <div class="selected" role="button" aria-haspopup="listbox" aria-expanded="false" aria-label="${placeholder}" tabindex="0"><span>${placeholder}</span></div> + <div class="range-items"> + <div id="min-${name}" class="select-wrapper"> + <select name="min-${name}" aria-label="No Min"> + <option value="">No Min</option> + </select> + <div class="selected" role="combobox" aria-haspopup="listbox" aria-label="No Min" aria-expanded="false" aria-controls="list-min-${name}" tabindex="0"><span>No Min</span></div> + <ul id="list-min-${name}" class="select-items" role="listbox"> + <li data-value="" role="option" class="selected">No Min</li> + </ul> + </div> + <span>to</span> + <div id="max-${name}" class="select-wrapper"> + <select name="${name}" aria-label="No Max"> + <option value="">No Max</option> + </select> + <div class="selected" role="combobox" aria-haspopup="listbox" aria-label="No Max" aria-expanded="false" aria-controls="list-max-${name}" tabindex="0"><span>No Max</span></div> + <ul id="list-max-${name}" class="select-items" role="listbox"> + <li data-value="" role="option" class="selected">No Max</li> + </ul> + </div> + </div> + `; + + 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 @@ -<svg width="33" height="32" viewBox="0 0 33 32" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7.74609 18.9141L13.6491 24.541L25.8671 8.54102" stroke="#3A3A3A"/><rect x="1.49609" y="0.5" width="31" height="31" stroke="#3A3A3A"/></svg> \ No newline at end of file +<svg width="33" height="32" viewBox="0 0 33 32" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M7.74609 18.9141L13.6491 24.541L25.8671 8.54102" stroke="#3A3A3A"/> +</svg> 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 @@ +<svg xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg" + class="filter" + role="img" + aria-hidden="true" + tabindex="-1" + viewBox="0 0 22 22"> + <g fill="#FFF" fill-rule="evenodd"> + <rect transform="rotate(45 11 11)" x="-3" y="10" width="28" height="2" rx="1"/> + <rect transform="rotate(-45 11 11)" x="-3" y="10" width="28" height="2" rx="1"/> + </g> +</svg> 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 @@ +<svg xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg" + class="filter" + role="img" + aria-hidden="true" + tabindex="-1" + viewBox="0 0 22 22"> + <g fill="#3A3A3A" fill-rule="evenodd"> + <rect transform="rotate(45 11 11)" x="-3" y="10" width="28" height="2" rx="1"/> + <rect transform="rotate(-45 11 11)" x="-3" y="10" width="28" height="2" rx="1"/> + </g> +</svg> 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 @@ +<svg xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg" + class="filter" + role="img" + aria-hidden="true" + tabindex="-1" + viewBox="0 0 23 22"> + <g fill="none" fill-rule="evenodd"> + <rect fill="#FFF" x="3" width="1.25" height="2.993" rx=".625"/> + <rect fill="#FFF" x="11" width="1.25" height="13" rx=".625"/> + <rect fill="#FFF" x="19" width="1.25" height="6" rx=".625"/> + <rect fill="#FFF" x="3" y="8" width="1.25" height="14" rx=".625"/> + <rect fill="#FFF" x="11" y="18.067" width="1.25" height="3.933" rx=".625"/> + <rect fill="#FFF" x="19" y="11" width="1.25" height="11" rx=".625"/> + <circle stroke="#FFF" stroke-width="1.25" cx="3.5" cy="5.5" r="2.5"/> + <circle stroke="#FFF" stroke-width="1.25" cx="11.5" cy="15.5" r="2.5"/> + <circle stroke="#FFF" stroke-width="1.25" cx="19.5" cy="8.5" r="2.5"/> + </g> +</svg> diff --git a/icons/globe.png b/icons/globe.png new file mode 100644 index 0000000000000000000000000000000000000000..7717876f0907b0c292c2ffa2ccc6bbea2adbec14 GIT binary patch literal 2350 zcmV+}3DNe6P)<h;3K|Lk000e1NJLTq001Ze001Zm1^@s6jQ+T700001b5ch_0Itp) z=>Px-=t)FDR9Fe6nR#qfRTPIiGu;=<QV7t}76nuqkVT*rfdHbiNDv5ue~3zAWHl&@ zfPp9xTtZ@^P)rn!F>Fb}4a6uFP)h*|l~wjBlv)KVmC}VyJAS{%ykR=?UR&TMr{~<W z-|gJ{rjD??XgzxLh>MDfN{@($NUyK2?|`d#pSudD(^*nmTkEc=sc|1ZeE9mEhH>1k zVb7jDTg1l3K7rjtuh-ifoa}HoBCY%&zXAUeP=HQOWo6}#!otF<R=RdPmz^M(pOux> zHaR(YUSwqCr^qG)r9cvaTQhqbp`Qg(fYZQa_$`2kps#{8gD*4F(^67W;@Y%nbG)>) zv=UyE&Do>|3MBVDsE|Oj!RtuqL1t$Y{dV9*`T6-%_U_$#h`tATt?0bGyvfKG!)p(3 zqsQavCR<=mOiax0nVFf-K{weNO|W0TeyvGvBY{2!a;mGVdl2|_f+pZO8(ucayd4k) zk;A`jkgY^#tsp!+JWjUqL4ONs>qv6LfB^$q1=xp>wh8v_+xK3V%ax18AYe4XW*<6q zs02D3=WBoy&`W@(+F}$=0&9S9`M}pK=usS`A^6EvEc%wsGC>(I;%yjf?_}X{hiT1- z$3Q>AGS7owW%BK?bkKbj`5<O&l&L45@U!SwDVAN5Aqi^A+00sk2Uv=)forByAD$0y zH~Vg95Z|`DZ{NNj5&n$N1%XQR|5FUbB4&=&a)$>RKx_Z31=?b`9{ie|vJZ0^?N<6q zHWQg<(F+7OL|#iiithklGmB%v3HTik3`5-kDAq6_8&HMt`|xjQm|+-CWnX+oG9#Fw zAN%y_lS)~65=Rqo(g&3o=m_w3`o96Epf7>Tuv6K{Mo!l|10S)_IsmGZUBP|9BaziY zS3)0z&be{p#y1581sD4F@BbKk{(E>+S^m$_F`7R?l1buLIF7<*6tsyOG^SsH_P{Iy zO3>>@KM6VpT3f|KKN1{9Uj<r+sUBQK|1z`=RWi_m{t#lQfH!EY)EZ<HNIHY0uSn=$ zVv<u*5sZ9!11nz#BNwxof_wpcDo3`Ip+o|=!f7Hi7!SQd>hu?*S3$oVx@`ac{pGMd zBrp;D1vOCv3Ta%8Z*pLsVL#49_=XgW1ev`==o!Fjg8wB!a3eV_vDWXPPv#-;KhOfB zG0U>`vORX)<TU(C0tX51A_@7r*iaf4cHqE)QU<Xa{tR+@pIIz7MuL&<)|NO>rMGO% z#={7TuV23&vNWavI!=Mv560ZZ#SBoO1T7nni*@)=C3##5hBFwGF#44doU^pL$;<$q zJa+7u_SYRX*%Ip%AYYb^SvyNEd2E?UQgG_Y1XjWPRt%e_!<2^O(>C&Evk5LA>45)$ z1{<TZ4H&50obmDTod}%7rp^ymH2dl4=}{OY0T)c(UEm_c>)g4sapJA~FnjsZ(Oc6= zVx0)A+T^xU7B3+oLG>>l@^3qx=JJUp9|=rMjGYZ<;XE=Ab@5)EV%4T{PH(B50nW4> zE(T-NCObWX*CF%Y0#<s=j(oW4>gsHlm^h{QDrZtUIJiCLLcE31POu7Va8p2yR@YJi z_)qjgPAW3lW&&zjB0HV-$aIQx0X;TC62+IQ!J{O)*k7noR3agxN7;)qB0Sx^#zcnb ziK*5cotv6C3Gi+5d{`nQh7!~$%DH;RgY_Z~Y2CYbZ^=F^Vp*266}E>c_!t!GEpm*n z33fUZ;?c-9P!pfF`#zyaM{qG*WU!T51{K~!F6(x7PJR43_K)sf+lE7zz*YeP%O)R| z6JgpBryYJ08Ec7?Svf~1f=(w>AsAn~c1;(41^l+bvfJ$wOFk-SS~<(cEuvh)m77!F ztpvZqk?wD)4b$y**WmaHkZPxM8~ilIJ9Fktjhzj%tiyI0eBSB%sLq2i#?An~Hf#}& zyEdZJED?k3f}*=IWtLdE<Z<m8dr+roGlm20z6*TLj)J>pWMtUh0F4EeOt!?jA2?>G z%K^C$*#Ks5vviDFMdjXsQ5kFhf}J69)xeavxHuJd%Z5(9z)}b^Iy$-|6QPH%d|5V5 z?=<B?PTxzxsDpZUQLxw0pUY|f!0JuNgS7x-)kOW=abC8Jwgr2)74N%5HnbkBssvtq z42568!?YZJ8UFQihX;Gv*1@+-kQJ2uOkkCA0^fR3lksXU=a~dq>Qj>3-$<e!SuueZ zQSYWgcVndb=_4A<wsER-7|Rc>*9a!z_2^fle;L^cbUT2H#rHc7AmA^fTg{;KXf;}c zYsUO&Lg6uH@dt`(8hc?Zj#b1pdgG&s;7Z^Cd_A31N6VPuN@i#+JTH}DI&>`jRzNbk zT}Zkg^eAXOOjKg@GS5N(8-8&pYVxBAWi<z*-3T6xqg@z}0_vdk4Xj&2KdWtJ0AsCg z6bAGqI=V@olC!|(yFtD}JP!Un_|>3wzpJos1BSxeO|ny<jcX2kYl+pNNiZG7TtGkm zETTw%bmYj9e+?;r7(hNrE`<CJ@H_I!F>A0WKDO~7kPW%-{np1^k1x&ei`c9L7ZPha zxPi}*7Y7b>g2t2FA1IIG)arxE-&peZa5$LlFx<~<f?)YZpfU*CcLP;f3HcsJtP>oZ z@qrUz>V+gIfxb;J7)>J4%vK)Bj5G~cS}liu##qG+*giT<ed*w16-n&jYj3;Bmro8* z9)6-obTnUx=S^84woFh8N)o5T81-(PY$MQzfNG#l>kQxlU<J@rTZw%a1}vL8`5?K~ z@=5<EVhp!SPL3OWc@!5HR{=TvDq6%W%tC*5YHDgC!S*rwqXb!otR9_T<g8=Ij@q|g z`kT}zktNu@!XT%^KAV=7HXrsU@H*3<!;CIf4C&f6%N=c{+^biwHrxa{KrbQJ=H5e) zF7(r(H)($ncsV?^rSKO5`kZxxRgMM&W%SqbBW5<g6RI{g*_xf8>B;;Noj?-^+#8$% zw!EK_={r~->IL8}ck(0pZ6|0ZDs286f|=7da|a+^d=jbvN+8`;RaHfI^KXd%0pkRk Ux?lW#%K!iX07*qoM6N<$f(TMm6aWAK literal 0 HcmV?d00001 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"> <path d="M6.853 0C4.865 0 2.979.903 1.678 2.48.478 3.93-.112 5.787.018 7.705c.128 1.918.96 3.668 2.342 4.928l.274.25 8.55 7.797c.234.213.526.32.816.32.29 0 .583-.107.816-.32l8.55-7.798.273-.249c1.382-1.26 2.215-3.01 2.344-4.928.128-1.918-.461-3.774-1.66-5.226C21.02.904 19.134 0 17.146 0a6.661 6.661 0 0 0-4.488 1.761L12 2.364l-.659-.602A6.66 6.66 0 0 0 6.853 0m0 1.309c1.302 0 2.61.474 3.671 1.441L12 4.096l1.475-1.346a5.43 5.43 0 0 1 3.673-1.441c1.567 0 3.126.687 4.234 2.029 2.03 2.456 1.779 6.175-.559 8.307l-.273.25L12 19.692l-8.55-7.798-.273-.25C.839 9.513.589 5.793 2.618 3.337 3.727 1.995 5.285 1.308 6.853 1.31" - fill="#4A4A4A"/> + fill="#AAA"/> </svg> 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 @@ -<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" +<svg xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg" class="heart-empty-dark" role="img" aria-hidden="true" tabindex="-1" -viewBox="0 0 24 21"> + viewBox="0 0 24 21"> <path d="M6.853 0C4.865 0 2.979.903 1.678 2.48.478 3.93-.112 5.787.018 7.705c.128 1.918.96 3.668 2.342 4.928l.274.25 8.55 7.797c.234.213.526.32.816.32.29 0 .583-.107.816-.32l8.55-7.798.273-.249c1.382-1.26 2.215-3.01 2.344-4.928.128-1.918-.461-3.774-1.66-5.226C21.02.904 19.134 0 17.146 0a6.661 6.661 0 0 0-4.488 1.761L12 2.364l-.659-.602A6.66 6.66 0 0 0 6.853 0m0 1.309c1.302 0 2.61.474 3.671 1.441L12 4.096l1.475-1.346a5.43 5.43 0 0 1 3.673-1.441c1.567 0 3.126.687 4.234 2.029 2.03 2.456 1.779 6.175-.559 8.307l-.273.25L12 19.692l-8.55-7.798-.273-.25C.839 9.513.589 5.793 2.618 3.337 3.727 1.995 5.285 1.308 6.853 1.31" fill="#4A4A4A"/> </svg> 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 @@ +<svg xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" xmlns="http://www.w3.org/2000/svg" + class="heart-full" + role="img" + aria-hidden="true" + tabindex="-1" + viewBox="0 0 24 21"> + <path d="M6.853 0C4.865 0 2.979.903 1.678 2.48.478 3.93-.112 5.787.018 7.705c.128 1.918.96 3.668 2.342 4.928l.274.25 8.55 7.797c.234.213.526.32.816.32.29 0 .583-.107.816-.32l8.55-7.798.273-.249c1.382-1.26 2.215-3.01 2.344-4.928.128-1.918-.461-3.774-1.66-5.226C21.02.904 19.134 0 17.146 0a6.661 6.661 0 0 0-4.488 1.761L12 2.364l-.659-.602A6.66 6.66 0 0 0 6.853 0" + fill="#552448"/> +</svg> diff --git a/icons/maps/loader_opt.mp4 b/icons/maps/loader_opt.mp4 deleted file mode 100644 index 7e9da187b91147077036fe0e79153944867345e7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58142 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%0A1tGxR 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 US}O`YreAj!r3MG#%EUqVcgSR)lmGw# diff --git a/icons/maps/loader_opt.webm b/icons/maps/loader_opt.webm deleted file mode 100644 index 90ba4065d4bda74b7d8a2683a50dc9df62892633..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 33962 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 diff --git a/icons/maps/map-reveal-marker-standard.png b/icons/maps/map-reveal-marker-standard.png deleted file mode 100644 index cfb600ea93afd439e7332124f68c47d221ac3bc1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15748 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 AAOHXW 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 @@ +<?xml version="1.0" encoding="UTF-8"?><svg width="17px" height="17px" viewBox="0 0 17 17" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <title>Combined Shape</title> <desc>Created with Sketch.</desc> <g id="Document-Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="UI/Pencil" transform="translate(-13.000000, -8.000000)" fill="#3A3A3A"> <path d="M15.764165,8 L17.6074619,9.8428079 L14.8422343,12.6079993 L13,10.764162 L15.764165,8 Z M30,25 L26.9080042,24.6721735 L29.6726673,21.9079783 L30,25 Z M28.75073,20.9855915 L25.9860669,23.7507496 L15.764175,13.5293899 L18.5293029,10.7641654 L28.75073,20.9855915 Z" id="Combined-Shape"></path> </g> </g></svg> \ 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 @@ +<svg aria-hidden="true" + focusable="false" + data-prefix="fal" + data-icon="house" + class="svg-inline--fa fa-search" + role="img" + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 22 22"> + <path d="M1.28 9.036c0-4.277 3.48-7.757 7.756-7.757 4.278 0 7.758 3.48 7.758 7.757 0 4.277-3.48 7.757-7.758 7.757-4.277 0-7.757-3.48-7.757-7.757m20.533 11.872l-5.954-5.954a8.997 8.997 0 0 0 2.215-5.918C18.073 4.054 14.019 0 9.036 0 4.053 0 0 4.054 0 9.037c0 4.982 4.053 9.036 9.036 9.036 2.262 0 4.33-.837 5.917-2.215l5.955 5.954a.638.638 0 0 0 .904 0 .64.64 0 0 0 0-.904" + fill="#3A3A3A" fill-rule="evenodd"/> +</svg> 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<Object>} properties + * @property {String} disclaimer + * @property {Array<Object>} 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<Object[]>|undefined} - * Any available suggestions, or undefined if the search was aborted. + * @param {Search} search the Search object instance + * @return {Promise<SearchResults>} 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<Property[]>} 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<Search>} + */ + 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<Search>} 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<Search>} 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<Search>} 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<Object[]>|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 0000000000000000000000000000000000000000..6469b7fefc6193fe7172fc2d2e6a1e6c69f22251 GIT binary patch literal 31292 zcmXV11yGe;*S$!0cXy|hymUwi64D_J(%mT?(%lWxa#6ZNy1N@hTBPege)G><2M3;e z`mDXy+WU!Ac`u8OLV^N;K+xsoq}3o0n7DuckPyIciZk^OAP^LJD=8@z7d2T)DtQ?x ze(pE?{Omj&+z^OdWX=Z<?W7w5(Ty8(Oijtf^SU(K&k!2gDx^UyokFZch;axOj(C4) z1d1jeK6bX8Mqkhzid;^_c0N;+r9@POZnTEJ;3?&5*$+SOvy+pDg@?9-?1RT~uL;aR zY#fG!{X%QV8?{hjp+(9#Wx2kg_P|&8a+`2eSo<SAmhk-$$T_T+r@!kru}7F%2goak z;~WPa&cN17aEl6q7KADRvPpw;fC>|@1{vf~AH;(U5<$Lws5e7_Btsy!UcQnvkP39j z@n3mSc*tUT(kdZjv5>4B2@(SX$zYPDdnMiqG1B;~#`dbK2a;bUPQwA$RRa^K)4(J9 zDzF_M;vTCVk11{h3CxgW8--8_zJf?m;EuskDZ=J+O?PgV8#;zI$-zLnMpDMo-c7&8 zYf70U@%VGTxlO&@V_PR_OvsF7fxJtTot8z5D|Y~^IF1H^klammKMRV*AMEdL?OPn! zp8f4Fe6~4vpb@3M*m!RH@(K=u^?u@P*>G=f9@cRH=9^omO}QRqSrhVU<Idpq3Ua<K z%B1&Qw3pzcI7M!O%Ahbg844=u3B5@{JJ(LM=*#OZhs|jJYw*|S74LJ1Q>J)EbyrdF zlasN%dyQJ`!3dlrxBbll?ejorz*8*q)HZLOJ{x}2g?40<3XRf$SF>7zH1$x*HqK!H z&jn@b6LOLVI;1L0x$Auc;*SyAF8@=QSJ4>S)9w&)|4a5SB&Z1xEK6P<uNw#?sq-#Q zoD~K#mR7t2fwcW1(*K%FsQ42J0+G)D%<@eF5v?1qstY5#o4l=y@Rc!wv^W}FmoSzj z!fqfLN&!)-1hIOT&?4NAZ?Gi!*qK{c-@Ca5(GNCp^gkat5+WE8<@OM@g(1U93?ktg zQwv34o5U{Cj3W_d#KO@S%kd@#7E>KY;HlE|4>}jWDwniQFp#7D!|sgtCPrWUK7M78 ziYMwZs!f(V`7=}%cJ`H<c=K0ElBl2gE>o_&m<EBVg*H<p-zh`_8w$&&-u-fOh4@9q zng&jBV_1AyGS>eRE@Kp_O~NohV7jNxry0j*O0F&7j!)W^YkY=8_bNIN_ZaD82v(16 zP<*_swxYJwSNdbx?>H?;TFAj6+&zpmNne$}(|hCG^?8~I)x~SStztN(rl8KH|Hz1e zYnUWQhcT46`yPdwKAuM&Ro+H!MUK7rL$T;ImQ}VQ!%@85(E2VCG_Z;)Euk?UGhR9I zVW@Yv5l^T;Vp|cpkWQ^Q8ApN7Tx<`nB#%cjBfmm*tUPxXsUkusPgmY9-(7|CLwGqZ zuUjVlSQdfQNHKfS++?PerPa+R&O_H0<h5W5nY78YX`5e&hX;qje?8F9!_b2V<V=`| zZHRq{%ZM6@IWrVX;b+n^2GzM1ct|rW(=pQ>G_+Z+S$y#mhH+AaQZ!TiQw}QBHC!~r z=DaFU)hjhHD?e)bmA=)eSJ^2?o+YT9D<{`zE$6QpSMyMdQ<+dTEb1_P3c5E%hlYi| z%@30+Qa5e<NgP8>@RP^<LR)g)Srhh%%_d#RL=Sp*tmqE^K<duvqxeC}rNW}hBEceO z-)Rg6<7eIG`J1I|g>mX}^l=|{Savb?x_Rqri)#OBfo1RIj%AY*=M#dHqvbO<#~kq- zwwx;=JE0pl<QD!G)t1nf6}O4sqBEvPUWdq6!&exGYg4jUc7Kbm2!Ef=pG{E;aPcDX z#}nIf3!0vf#+&}KNw;d6IuF_UiaqkFC86(A(Ug1<V-v$^xaAI?C!wfCl>?bc74N$F zs*SL*i51UEabI!&ZU^&NvQ4H%%hdN7*U`~Iw$-Y|s-ff9H*?uGxyBt!Z7Yv4p(Qfe z{R+|i(F3W=slFrdsY<CgRq2|dnqHb$nhRC(^ZG|@M|SgCwko7rS!h{8StAW!tFx;6 z+_UU9@V4>dn5UVqKG-jFA3q+W9A_RQoV>X<zNWvnyPhB)CBq?~|IhV5UuRdhQFmL% zwxi-L-rn-Q^4>!&OH#`Mmz+yJ!qRu~BRFa9X@no_%A(5b^Snn=_DD`!rhm*_6=k(% ztyVAD&904#^!rslC~;(w4jJ@{oJssWztP@y8r{h$%PQ-+j6dM~=>AdX<3l@za(y;K z?mU;>;7Xs~X8VYbk&vi}`Th7ItmseCS9#uf;T@$NG#ervwEpw{8UBcmxex!{k37ZQ zc5Zq7A-H%CZv@i~vj@}undEaltPLzBTn1bTA|HGVd@BMcnkc>`8Zx>LrE`))7vUe$ zJJZ*zmTZ|*_`^s!_`0O`T%!z%6!X}zVG~FjSpE*BcZ!Fj4M-82vbGUTlvczUgm->; z%q%<=9KTh+e{gkjy<wZL+SPfmY>&N&EC15`-->mEv%6)LwXK@1>2X$5U{giYXV36k znTs?WS6n*elgQn^#Xgb99hX-JwwbT9cQe&fT}^BDRtuX6_YcQMay^7~_;liZ1w+$u z(x75{!BmneoJ7idW%f$sxPL;jLX~AY3S$aY>l&LBgcJ93G*hkx*Z%X9y8jfW{MPN~ zYh-0LR<8u&#Aez{B~9u1^{R;_rzQ3`$=S)-MQZ3)soz-_xEh%nl`U9VlhAj)_U(pA zlfs8@c4_ZiE?4kvLlvirzN^)!U=S#Ber5I4BhvG1l53aKoqfGw@%;Oa7Bz^#w>|!w zO4GOc#U@Xki%ptNnN|YY=nsS*9NY$KEoPop=hJ>8pGKgmPb%BmY8rp6y;lFkss&G% zlt{4#Y4N6%Cl8I-*ZkFOHpMqiG1EA+PMg!y3}7KlF|2Z|`dV}Q?QPTkw$^*YIo~TU zyXEC|g~s8NBe|pEBm1fwoz7Z?c58{b$*^<uYSN3CJyJtEg>}~gZ|kervF~HUW3YV2 zd?$LN4OJ_D3iykzy>kK#Ml_$QD;qnO7?$Td*F1bKF@nkQ|64yxK5qNL>ZxVZF5`D) zKYbds7Dr-2+S0l2zt$w{q1>Mv=TqW?cr9~wWbNu;yIR}X2}QP3<By?`6LQ?k3UpY` zI?43-;i$`k(Ic_f&A3tO>$9Wst6;o$wRdbIBcSmL=}69tby}#?t%|+KE?=ae{p{D5 zq8##%<gy)&FaBrTJEm>}d~sU2`8iA?7k9fsNjqg`Ww|-;#YAQ^R=sW%A2VzWR1MlK zr6zL42whJV8x3!UF4qs@49?pe|I*x;tk5^Us}`vdk@a_c+LEi)b2u`;89C7su5taV zboAfU*-XH_b=REB!tDZwL7rFp+0SFrMtg=evL^bLt6TT$6G9J^Q^i#S?>Nue$ByNv z--!2^zXQHM*O_<OZQdGn8BO%+V4h(hhvz;gzs!VhP0NnT&e6W5jfu;lq$EWrY5!w3 zp0rg4EyJA{_SbyzI&!Hz*m&B0csHT_xOk3w7d_Eg<5hm?d{$XrD>OhmK-o6#C2~>v zSUtYtch+>)vN6z3nZfiZ{;YC2b#FRQHmQ6gdf@x~$axcT`M@qKI03jNm8qPX5(MJS z0D%OCKp^+vSAoAE5H~Ie<hKz7B9sn+5ISbJs0u?M4ngwL66&7WCpk|pD{nIoI%&jl zsF>Aeqw)W9X0yy*Oj)$Cbbg(+yp&Z@S-C9c+jwek!E@1vtCvFfAJkNbM@a$CP#lX& zd<}ECYz_`4Dmmh9@Ue2rFo8TaRm7=-SZ({Z;5acJN~mN(I*iC6t_o9P-^~;2Q%<Rs zizsd=jRDhzN-+fvWa1J6>D|KW-QxOlm7y5+6fk|nz&++OY4gj_U?Jrf6G0gMUV~aO z3=YzQn-rssKM$sl;=;s&ypz(p1PpD^fe;zS*2D9lml+T7u5+{YgmOW;fPyfd+SY@p z@C51)3Z9J7bgE(}XvxH2@5}pb$vlDTybpDYpm(}goobjU@SHd!LQZcZna)r~4ld<$ zawQA;q^J@6dEnlzy=kf8td<*<OwT_zDXb}wqyr7tZ9Y6}`v!URLWVoVFd1~1n)Tls zn9A6K21nsD`#d+=|13o_4)=U8Dg56+v=}}aFxAD!<(o@(d-ifcn2wa4+>$++N*P9F z3b*kOX+|a0Hwv~{!1opD6u8t&Crma4$I~lSn;Y?F^82K?!*IUrNh3#^5P&ZcJ?=_e zpwbhK$g9XD+q}b;jvPqrMQl$oV(M`ulhrAR;!gHtqwdZx*VSMtrS{xlYMgg2>NAZ* zDxI&uFWwefY^Xjhp?@1WfYa4VK1-!FN*WcXQi*3C!-l9JOn?XFLBbzV>5Dmg_;vFD zNq%QTtg>Ba^cAK`E;HX}mJ7`dlY<I82S-nWLafR$(=QV#JG><$h5kOY>g$8+h01n< zS`fTnsTG6oTa^C2^P6g%1lOWITJv*z2t7&E_d;p9C#DNI<c_mmDBGvZQiK)!h~?{4 z3bFtGBVBndBFB)_Jv@x^kaJC3cA#Fccg%z>5x$M}m?6L%KAjmk3@apPvdKdg6a za2HPBb`9mrsb#kqMTm4TA+eC?6NP)WE&oWzYaCK~-ko!rLTLT;XB<p<<m+r*iyJ(H z=x!q}8nobUBeR}yi+Wp={l-@G=l{+kBMG@7w4|ID<WYCaq(ZVdDf%1(;{h338^bvA zn4tR*hX)ll0$fqjy2;(G7LKe>h`}&{mqSauN~vL^(K4Bu6^}h#ZfkWdNcZ}ztgM53 z&VvV+?*t;38)CYR0&V##r^o4KBEzN6?I==>yC#d5&Tf|Ih6l*edW~5Bm^V>$^@|v- zTrU!Ic6l9P&hV!7{3xn&Xja{(h8anORnD7nGq5m7I0{?k8)+Ku_>#}}GrG!{h)c<Z z`IOYCZa{V!tU4DjH=7BU@F$(chA}o|gN1~Vy;Ku;5r<nMEaa%8mPxmq$RV1K3+DBs z=CYBpl5E!Q$bK~+YY|7FkidTCLG&L?c^U*vOlN`^(fBI9gIs7tVRb5%ZvNdr<nmbU zm@cr;I<58b2xE2xe_eSWyLjb>tCfHM#=6NXz7c7zb1od$ui)IgDoJO|q^0=>JdWhx zUM6yDxx&vjV!~YC<z=B=prQh+!{fUl+qe>HuERE-JZ7QFgr~+y7tLEb9fpGmGs(hy z(xJO?GiBeH@m~})KiQ}l@gC#O)v=fBTT4sk@3Z(*dGpW+sYuz;Vn1_fJJpu8Q@P<{ zoG*W+BqdMZLbMGGWXjbQ_Fgfbn6iT*M<A~=X1(ncyBpQ3CU7;7t<%;g3TQSVyYDFP z=;KvNRw$YE+pwbau#$|FMJwhji8llrHpYO(hm?jo;52#YI(g#0)I)8TS~@%!MM+QL z5~fB!lPoWj1Yf;4EvUn^U9e_fv}O+r`E%!65p+C^5=jUi3hGpy;c2p6dE2`7(}+u) zAxWZ6Te?I|{{A~2j-*p9?>Z}Z5D`2W$fGsw?4p|*YEh3&<yR>$1BI)eotv9ek3h=1 z_!@M0DG@oqSAZw6G-fiKY2CPTE{vI?Ak$omrxj)`E0lu~=wj2V&+$$KAuu{L_jrd7 zo#~>9#>)9a!bYYvEqbti0>-Tja(t|3hO_Ay=ueA^UBIw%PO@`jz=%t3Y*;1Xqo>DL zW1_E`mDB4E6%4!gOc&`1yiMi0)dV&&1=3Q~BqPvC>mTVppIlsCAAX3UL*LQSO!ici zCs|c*u-m!4B?^3G73<&b<uozFs4DAEq9RogE)~C4L?3W+^!7$sX;5WJQQIh!s}qEK zDA1_XU?wzF5Vw$x9H1Zc{cw@ewVRuGLWYmBDJc!QRGg7+)=#ykW_X}c<p;fp@88F0 zR`yTR3GnmJj@8J)x23S{1j!-~{skK=KJV)&N~FV)rpl3)BuXD@Vgc5>e#uOpe9oHh z@B_)`pPgSg_;pmav0vR;nzuNCLsvA_WyGb@eNJ@l7w~(b;z(3%FlgM22h3ir`g+Km zedU!JKv%~>Yv{0Wmsy_^8Ctj$_%zj^Zrm(QI0~Bi3|xkY@PnH*;9o|5?`jZUYBgw4 zKWN5t>>(o<yYnsIyIeid+L+E|^tDy8=c51*3gQ{%M?*s`4i*fO*_xxCOJOtw*y{j; zg{97x8rrO}SAva1yh^6HLJ$zQzz4DugcSl87J>}HrT6ydBI5eI4Ie2+Xg!lRagg%O z@mwa1$K0G523a-|akuTw;4<iaadlPyH*(nW>VL7l9juw@=)L0nS)rv?TuMoipV^Y2 zXAh1BX*v?$Z7BW1^1psXZw!1}^x3`<b%wX3`NmvdpJ8k|I^4FWyHVcoXQ1Gf0!=qk z&3d^<BHOm30oi~==Z0+OM!4O|@kh9>kUlOn=2N8v!PwxP9aFF}R#ox5cqq|k<^59p zTbtD}KYs8rk2BkQ)@?E>8!Q+TAw>0WPgnm9((A!#){mW&V+(S8FI6-p-no%B{Hl@d zoeq_@$wfSKQqV+kVslJy+h#PWJliLe<aSycocLA_rf(xqB`!+Tgo4sY4;hYncxduA zb&}&`7g+Jd#YOGG$fVIH`^FK5PRW9<RUUa|1S{InISnQ}0$pFf4cYo7>BbeA619Xv z4znfp_!3;qjHnkTE~XKqbb%9h(lvX+_K$JZ$x1WQb=r!L%bY0RQBK`kzp+KLM@@gL z{i*Sd4fV>~Ak*lc2X#;_GMuePg1ITUTRhWfvu#r#_m41UWo4z>HMg$%Aibxz_ppg< z^gc?-#m<8$ONzqwj-?K~nN^<@!4{Q3iXu$cQO`nAJgQ8XMEkMIey-gLWE}Q1THQ>c z+m(1Zi>)Y-E!1-mXGfb?Km%aL(9lpsr5U#90*$1|6qApQY6ESm6?cQ}E9m()b+F@Y zN1SdogU0y1Mu8?ywPtibcPf3gC-$aZ5oc7&;6jM4ogKY5XJSet+AHYfzb{Ol`ddwu zLfKHkoO9kIw$zyV`5C}>e86yPIJxrXLZiKsDgU91-e6~HW5cS-om^LEO~zK`IY>o@ zC!E?{+`-}t@tyMBk8<(d7#JEtu4kAz*0kf#`t(CLG9b*{(U`s1b5P~Yx-Gx4j;8ev z1Plq^VknWIJc2UQ_w!}S<@~&6k0tMazGP-TKK~`SXor2&MrEYnL6U9%7``J|?h31d zMHc9KQis1yg?;f4;^2>tj>=mwnJ`{KR#sLbv;RYmU^|K1ACrs>M>Eu{*6cOniZ6`m zVLQ+GU}IyWz>5c&_A@-U3rIvM9^qo_@9&9_oi<F|5&XJv1c~?jIlxxz<*X7B+$fh6 z%Q&h4dNtRHgr82z&Du6G?xP)o;G3(LFmt`n;0LQiN=gbE`~V`*#a1t3s#6+4Bio!d zz1uhs-T7!%C{`SuOQpOZz<ZXUXmywAqbn6AJe*5J)@dbxFNcTBXuWbV(fo&W1UT_B z@>@#no+>K|bRAg3zq!z0v$C>!;>1|3$j(u`3eqLZX5_~tLslJ1)R+^vvvz&?9Msq; z<THchH$v*gbL3!(hv)L;5Y7B<9{l!Sr4W-~%A+8~KePOujS5HWq4wE~_fV~_sbNUH ztkK2*`1$)v$kjl(ZTNvlPCT)~TqlI4M?Fe5@duf@Fk`DDZ<Z1nC8gAY+iIJaO950E z?@v!F)XeZ39eJ%{YhbaOFx!Y)5BBG?2SFhrg&0(cy7a|{7>~1cd52IsW-6<RE`=OV zx;@TfMf41TdFsZ`#zf^6e5!05KXjG6S)~zR5;XJ~6Id=B$q;+DRGJ*W>$3Ah%<wQo z8w#q>Lv2A@y447qZ5k0@>@(@$>8fG=&PK?pF;FyDn2o7{qbVyZ%hFH~PXU#jpPw%t zRZ&zEPjv1#3|9wF?L9#)HRg`ova_={f5woi<>pCZhY<W1X1uvSmNk8MFE1{=U|&)f zD$mkK8(9U%0!gkbY1t736M02dODbLv5tJw-Jdwnco;$m!toJD8eIeIFnLhj5lw|pY zKQ=ZN5WBki`lP>y#W))`ralf!n$tWppLiX8eZR!SpdbDuMvnL;XqgEoZG9m}GcK{_ zWR8ciSDKkC2yddB9IqccYR|ST^WkrYuoZItJ5~z$TgXuMduK!_*X?plmViU4iAExv zYS5Nf?l+f7H@}L7Azpa~^s}FdW9aX)Od?%tOX#1et3Bzk4+MpU;UKFmZtoVkIeVSR z+WZ~}T55K#PgdTE9O_#r>1)ve)~jASV#cE-`Zl?V?ea!Jg+J96wHRQ!e<$a6_hBcp z2Ga5>Ic6}3r%y-P8H$!8P)A3{z_~e%ayTWpnWh|u$bLHN&AgpZBi#!mT16EO+>DVb zJe^ai1aLxpZ_g!K1d&d#Se*R<{y~ay)F=-7f))7_oMHh-S!t-NtD}y|kZUhD8;gt! zmJi~N(dbrwP72v<xrnb^Pm|zAeioHiOqG{o!cb$vE7s+u-^z$@ewGrtz&fcZ%-8;* zoAP#R$hXsJCv4W@s15o3Z#trun)(-I8&yT`jr#g%J%4j^`o9<Q=K;7fi)ML4W?W<# zoIli(Wq2}UJ6c<_j)xuvk`-y85nPe|f4|GoQxpm)6eR0sEXR_Ok+I1b`KE5VtKjuW z2d#55K^B~33<z=HKH3K1LfRRaSd3rcGO$3Em@Y#wi$`$Iirz?>q06b+WpGH5Twvv- zz7r@_WNxlC>_i9*wR?@jK$Hg7s(@;Ie7v+lC`i%9PMUiNB@!yy^UR^39`u5ksw|Cx zi8*z2<@j)I$NUrCEf*`}Gkwv60%+}|Mo3sVq||D<csJ2;5ZaSTIHOCH1Q#5DiG$({ zqa-v>r;06c?vwqu{m+Dww*f;^#;<MNVxRDE{btI5$2vS#$KOzPmOu{B8p;Kh0UqrK z3;a8GHv_|_sEZM{7^ZF{+-Ui;ck{jGnkZ)^EfrrwnlpN~i84#OZ~WW?E=<yf%+mL` zW88W=EeN9N4t;#qc}dW-b8{7TrmN?y&;1M+*H54td5jzsh4>n!kmj?8#f^<{V<J>~ zGS$xxxm%8cs$9SLW<-^1k#XVu6ZaS|Q&R9hW&ZU4*8#()?w89v_TOZMs~=oBR43pk z@&cZrMh`TRNXQXrLv-KqgXA#76z2|RQhI+z{F5FA>CPWoKNp7vN4*f}2y(b6DVZ88 zLWP6kECKPU*8$w;S4%9AT&uz~XLVbS5#2_RjT>2i&u%o-h$N=>QAOD~IWZ#2smcsV z#LfG3bgu0L64Lwv0tHqiqQ18fNF+)`sTCjv;_V+n;K#!eFFQ+bv`(T?9g#9~$FIhL zF4@vN;9tf&4yF$HYcS}Lq&1fYZHotC`-+GNkYOx6UoF`ZH0xu4PfHCdZbaqgKjR?y zz{}Bqu;$2wi|E@yZxyY#@US0WwO(}NTeEi2dPz4G9&M`KG0-iR33kM|LJSS}|BgAz zRI%1wG!5Cz&)22+Up9ThbO`AP!i=fXMoRo?{`%!1%Xe;I;wN!+N1*g^X1#mI0E2Dt znM3MY!(^M9r!}e}LCM?j%p5EBtM*S`g15;=>7|h-b?Lt&?R9Ne!pwCX4Lth#@l#^l zGMe-)B177hGvnpOF)%PfN}flsgq7*`w*)aD+!B-nPU&Ji@>`on3IsBO|E8=%{J7JN z?;)Be3wSCG6BEe{@6v%g8Dh?R%hCRRTmsmF0bV6iY^tc3_!S52e8JvuOv-#K-NK~9 z`FR3>lkp>aCWKI5(YP%~q?HC-QpTP$sy|OrEnabPamJYz+xWY)!~#~0OB&6Jytgmm z$kJT5<X;Sl!n)be`rq97=CXSNj^4RJiJh3QfvxeOHyQ~Wt?Du$i=VYvw;I~7ljufu z=Su;?KO-X}?S_GVdMWY<bW`v>4XWvuJ!?WYg>^}`s|X@KWO%yBQnM$7R)H5BK;uG* zN?UALy97YpB9Zfi#o<h;l2&z8xf8x4BRQ(drMuD#%>_i9bI}nC6S7dy1n;Jr7ksQ` z?2!G_-3=o=@vg*C5z5bi4EJ)M{|R!yUWytTg!9E_TM*;^N%Wt{`n5R7Mw56gDP?W$ zd~>?iH=a{PrTSExh?0wnkEsgJk(`<}vWIh`r%^d)wBr6-H&(i2J*=PaHs2$;HOlv- z-vk>3c#Q=bcJ0(qpl52_RJa1qtJt31{yHqPV-XYBen)JH_-in{jC)dp%RoU>4?Ad( zV&?mq#W;z-z|wp<u<5|dbRIF!c?8k=U5VOu2G>ZCxy~>5R$8Y7rV-j>k7vWp9vu0s z2!MbP@j{nCxf>0f`GL8cnQUxT31<@F!e#pKDeE8Fn7Q9k5G6QR?h8T?*P;1QS{paq zL};5W9MC1U;XNMvwU&|)DJn2tK)U|jCH^e(a(7CMa6#bA)X{lO9dsU0Ev$*Z;}vjN z4>|C%g)O54@}tjDP`ZBU%aeAPvAz%Kz7DpDSHyQA>3!XB;}!n(cAxO->gvANZ5E#y z!oMyehz(7iNu=RtjF(!mbjQQ3<7DiPw~V657qLeFuHfhA$I(-aay5)R^`WOT)J&d} zK*>w~M|l4cksr;obUh}ds!wHw&+r_-c`N3uSwwGmRM`l?M@KP4y6?u%BI6t&g~Y<u z<m|@|Elm<Qy1a=Uz!`4Ow_u%{^=TqsgXsYpJZACwT3#e=q+sI=^}*kyQCM1se0EuQ z=(u8`*08KJ)l}`?PW!iAy65B(TCX_ZIQ=2eV--pUBVG!eW46(kTzA%xipiuXF}3TI z2$>Q&PUT~6OjS)pd{_p0vR%D)uS*M>fxJ*K;TO&g<W%X9XM2DlRk_vG?DL1})M9(i z#Qzr2n23$y)t8?&zix~KjP9{d^g21_tnou7Kc7T$kSNUn&m-!68LrPU)1v&F_a^w5 zD-AlWk%CtISwtA&+Io7?SAt2g8%I|-oNu$k^8346D|)s`c?;qr;tf-RwyYG8RI-b# zNB~UM{rC|IT(MWbeM!9_rqN;FgmR786g8Q4n9CvFC2A7<Su#aa5m$nlr4kEne;=N- z<6!%kD17|`vTr*uN1*?luZ%(4m7f3*a2#4ZiMu7<7j~l}b}kn$V42w^E6@YYQ66~v zDt_XiuDw0Rq)sqViSWA+<ckh<*RQ7p1ej*8O9t=u-1y@%E^yLpe2E!(F;y|Un<B>Y zqy<uuDru93=n|DA`Xd0JS3`^{L;r%fL;5$syBe*PlVu;X+3mfuGA$|BtF5Q{pwF(7 zZ&8I0Awtb_dexf81M_a=xd;71FF!2^A|l)t8*)A=$yRH^LX7oPJ`y^Az;46*kF7%@ zZtv2|>N-ZVPihin%RpPT+T!+sk=-fYx%mqqN3%y)(p4o_S8milC~2zVwEKb|w`r;{ zIzoTQ0cIWTGANgf8inBVZ8Hut9RmS&zmz3yWN~>p(V4zHT?}j)Pz{i`?8+TjyhMn| zsKq14KMVUQ*q@~(?RLsZ?Xy)#pBLA~Vg<-C!hzh1ZM68lV%JoYC8q@RkLSyC`(Gyv znkroT$+EH5s#`@C{1t6c#Tm0T?4X)=9cq4RR5>46S@B+g;OdDpsMbsvGSikCX#(zF zAG(OcJ_ki{sZ*``$_YPEo;0i`s&7`b_MqykMsLv_{Nm3Y9309diSRG-tw<Ug8o-en zzQ#gEm<~Qw!inWYk`v7v0Q$`2w9@)Vlzk&<fQsJ2R*OB`nTW^5xiF`DW$<IDe4h}| z7JxxJ3v_+^!XjE3<so)ndk|{s=B6=c$3z4WTTf39sB%*$EF3H&UsPjJ3>q6Dplg6i zh@1_GZ+-gvmv?^We14}bqta8Qigr9QT+JoPu?$!Vnw9i+*};rWWuD&(jK5_7;-P*b zgIAzZFVm5O+_{P5P9C9fkb-TUpDulWy1Yz+5G-+A-s+@7>4Fd95Rp$9P|OzOdNNX{ zEX4J(h1P;WR!xtW-M3+1^4?J+W|kWJ_>hJff6=C@If{j!E^4_P{^a*5GYrgjXJ=>8 zKu+S%oiJK2R-zYv_!YMR&@YVF*3JV?MmhuvC2F<<5kadO(2~8d=86a-cEt8vLy`SU zFAe6J^vtm_rPT3vH{_foU0+dI(RH*_6zJ=&>47T%>AH{UmjRTn^<Rr%6|b%Tye|v3 z<!AK`wOw&%S=Erx!pB;-4pDPL@9p}x`UjPAjNlZ3@6yh*QVJvXm{Ns&c?E56&xv@5 zs)_^qa^wWO)zumI1D=(cF|5Y9sk0m0Iw9*HBq*UluJ$=x(X>Q6`GFSSa%qP|l<^MB z)XMYqIqHLYxL~M%t)rquDAE*Kk=U;`eFoOe)`3Q~CN~*|sS;_a;9p?_7Z+^I1vk@1 zA@6pI6+}I{F$>(>gSsB(j4gJIC?`1BERdN2z9Vw@_Hy!wdFsXBhH3Y(E7fW!Jt@Gk zFk>W6idkNC@Dn+E;_}|m=Vw_KL)ysLj;~|^8{<@lc*hyA3blt#y1qiZxGJLzw^aK* z6ZQVG5Ja2Q4eIC35=O^;J}=*b6vurAI8rck*e9BRNPR>*zBtgPm#VKPJor1}wMgU4 zA>H&~m1ZS?sKaS3Mz;ZU3yq7zrafmJjW4YG??v-0Q4{k~qif-}aQV!Bh=YBdUszDB zT?nN}TTbmGe?VHH|5E{4+qQ2#eGqL~t2swrbt3!gEy7%18IkW8@*@Gl!G=Lk76on= zF1XOZT{D&VYgA?t#7UEk3@zSX_o0<*)(<;ssnZpqqquC^U&y?&(jYB+SGHu!VB$7= z<W>db__QlGil`0CuL_ir3YzF5Z8nxImA+W3Oj8M0qc(X<5^HfVEGKVomcMPhN>U|i z*f@fDvdgDG`ieHr1}ISFXnH|l199n{xf`6r_9kR^Mf$w^!lbUrl^eq!+G2aI{2<it znqK;5y9ec{2w~6=Hc|b&O@*n?-fxniZ((nETf)#a8-H)i;)5q2;Ps#Qvm))Re29%} zD!y&f@d@g`-9qwV`+dMt`#~|x@S6%&&c<s7_E*rly3ZeL2bMV^sq!X@)LD@0L~DGi zYuPAhUhSot_h<IwBj6(sB@Yj`76rkP&cGPK2lDuZH8tBNb}9s>p4utRBu9+O)oGi} z&G<gXq$hu-M=iA~RAJf#TmQ?cJV@;Q6c5_Y@<X<vj_~&T3A@G>02n>hu`@Ft$}4IZ zn=5a7#5$aXv&J}L949Roc<pSdd&S<qIeo<B(oz5Ahp$znR{kv#i|9vUFuRf`OoDtd zu!SejZbC~>GfN|F`AKb-Plc=ESnslb1v+6=**8Xm`{sJiPlEodg~NdU4OWhh4EkYU z^r%3F2_MZQZ;TriWV|-G)QZ+>BpGEWS|&X(o;3CJzY(Yr(a(sh0yuN;ia8eH3Dfqo zz)PEVH!NfHf643&*+<;Q4=!z2bQ3Nm%he?T@wpNL>IKA1)_@w;NZ^*n@y#3S5a=)9 z>&~4#00J4S{!|%sTJf!?bF<UxYe*jA<Ezp&`<-w~|MYYTNdsdK4-dwU+5<0;F9HDX zdH07N@J_Veu3$t=5O-y3L^{2bj0=-e0Cgavpx{xx5yk`wCJ2Xg@Tr~XiDBwdOLKGN z)03ZeD!rn3R6h-fSIZ~vwoW7Z93Jcob1C>#?=QaDk%9!}Ck>CHsX${QHEr^=Ip+}M z@S_%OL&LNh98rwhqa8sE|GPcHe_hzXLb9?0qnohkC2j5PW7W52K2GpD0bgC%=Nb*T zdpcyP=37%mvcNevT`+Wd1MW&f>-7bV#3^g0p&1sI(#+yrbT*~pOo-h|UF3ipq1fvI z;lal*N2phRlu!PKpH!)I$f4#87r10x9Av&0_<R+5{6KAilu!q7jWxBj5+)9o;*5zD z6@_xf7v1(kcTQFsZyz>ZVu9Uj;tfPv%v;zE{|9!UdL&I*196h%Rq(yFKa)_RQk#u* zf!O>HbmtGdO)<iI7o!nst^G!AMMqn!9k5zdXz)KWRGO093L?S}n&Wm9HwC<if-YtC z-%aF-WGzlGCJl963P0ZN#DT~xlGOy#M$P(fYZrpg9>6k!XTUg{JkCCTB1nz!G}2`z zh^FO3!@H!4PUwtv641@T2fVGJAuK5g*EpJ2X(oQmV(P@*G%KrShRuoLB7aUI+b6kk z#jYT!$+j-`+xJx?{5SAH%;EkzJe7eL8lTQRK(4Eyfj_Z7Y0P3^J-jMLks%mFkM?$v z)KYMu6iRSJUmjg-9UOMnT0L&B77V8jT_F(8k8R?OE98;EAvt9@@lN3A>Mxk%|Dx;a zYTEpB%1R9O3Mht8jVnEH#UL;kVC*=`Usf+$`n_cA<}iG3LI~Xv1nH$vN$S)aOsP_| zN-(lb7do=PK0kCdlGFpg+NlJCa}F?0*zZ31io0-)rt|(c>uQG|X@(*Kpy&NSE!K5( zU@W)+O}^gd6@1qDLJ{*;%HV~#pdX`?sBi<uYbEVdCc+G_le06;ar!Jp`NC1ZJ6hD; z*rP{%o8T!!1hC~xtx!<!6ImroTQI16F`bQgMHbu)@S<gyrImTRbz>_3{GMYyJ~=5^ zIpKi;yFigE>=kr%b(L_lvA+Iu!7w0ZrP1MaWua!}*5OQ9ffJT^2YQ^@=WBG#Q$E-) z(IK{Vm<kP8IyWF|WGb?*;X#}Y(1-=C_zOXTU)7)zbuS#5h75(P7R<p0?1t1Q;&fhY zW$0oLSQ)UuU7VedJTzpO6vABxc>$vPhztcFhTZ<ItUYhtcml?O)C?~H;@x3c9wbC> zk4MoPDuJ4p|1K3XSNX#rGvM>UDh{#NdUqx7q>*jtmeyU~AG|Kefx8A}`_)2WBTO%c zQ@zfJ-H^hDj3DwJcZ$bC(}2(b&@qmVmuDZK?!MVrWioU;-`gCYp1%KHb@<?Qdrp12 z(Fqd9pwNMApgT_NIpSa<ueZUDp6Y{SG^IZ$$$grKF-+0PN>WFvx*JEu0e<ty_Uh3N zOHE~Yzo{VEPm;FZ?yXgcT5w?Ta}VU}yl<W^?2F(!d5Sio`wP{pw`cmgySwGGO#qMT ze0e&vbT;tL4fnY@-MZKrpuL$mTByDFd-wsEhoDQGh8Vl^XUzB3@&oB0{<<0vW;WPl z?(5%BVmjk5%qebTYq14!L=7Te?Cwf0k2j0EdT6Jl`nUy^zU2JpasF$H7DUkZ_(lg8 zetG>8x9(is-tn8#jJ?A;3?P>z7wrfY3PNSr?D1pj<tsk1mdODbU1^4-NcL-|6y1{Y zKH5`M=?u6+6U^i}83VB-8bPEWT<vs9PvSL=?kFFM^=iVmjVt{|TtpdtZUzGYVbA*s z4MDsMZ}<tE*=@`mt!Dk7FSnF0!9PSUo-T7ZJ0DK}YxBM1;W5_=LslUG*9ugU3=%Ym zH$5n%&aRo%G0omnEofv{FPE=d{a#SMqya?gX&RvavNs^Tc%hOK%bFCAeLaXn=)EeZ z70g3sJRl%1uB@2Ohn+nDq7g?aW^ob2w3SY<A+{lZxz!u)`FP6>7O&F4^I{tw?EFJy zhZ8*&;eWqiwoX~^J8i31cGj;rk(yJtTPy)7eC($k_XIiuczU0Hekhy4?)YzuE=X@U z>;wf#yt%TJE@@~JAcg1k@zUV1ql=3RT}L+|3XWFu#Nk7;-vj^i?SNPhuabowIAEJA z4xlV;E%xsmE6f|iq09d4N1_NQM^cCVEL2*3xdv%rj*Z}=WE<O~H58BqHD#Geko6&Y zyM3A`Z>XXot-l}x;*78gQ|3qw_J(~9xHn=ikFTHaCIWa~nre;~mzKK0B^7~oN|W)u z1ES$}Kg&Uz3<!4u#B#uToUr=e%n}D~yksM_)S8LU@PgxjqO?--_q++Pu@8EbX|cIY zdT>oOth^$PbJp^EHBnHZTLQXfeGaF(q|a9Nzd;~u^MA_x@eHE$KYZx8-9}{~FfQyK z;HiP_m*@bP1uhRy0RY2)0fmAXQQjM$9d@=aakH@o{^yLldA>T!|6GA*!2hc*pgP+W zA0lP-fl}!<qT*G-pfjZ9j+v9FO`^{cWG;+iECLC>vj<RkKpyD;+3CTiCo!W|yu@`M z$3wjPGs3xaL^fdT6#wn}h*9bk502s*x{J=x_oMt@U2k;8otkSbbi|d+8wY-<mCgRA z%25f}5TGkDd)O9k=P{TXN&{wcNIeYkCnt7~TbKbv(GFO~h1~L34BX|VJEv={|CaCP zHdVmWks%|2AH4`O1AKGPC)3(*?6&6rI2?MmW&KhlX`Vs2PQ@5w6k?~F8QEe3JFlbN ziu-o8T%qRlV`{nYz-n=IadCQb?mJ1~wqJmD6O4HK1pu`6kl4PTNn)=NgyYkFTnKnt zsPm5Re7>r!GE(iA;9yEo0Cg^=Pc^rrQW;foKt?yr)gXPXm(c&!BAOy+Sb^pgiMZDr zt*fDzN}q$uw0Mg}RILX#a#~xSM`FeD`MehsxXy?W0-{b3gegRQ1$iJn`WnKQ=}kp& z;tOtI(G^O1wy8iOx4+(ULYs_EZaT?CZPNepd&~Yn$d`o>mT_fe<xsL(ZML3<6K&Xo z1%fz1C23v02$a+h4@q>9Ef;tEM{VhY`Y@D?NeoB^3sbiI|KN&@oV+gIx?J7FZZ=Yp z;WZ7OYE6X~4m}l}Jsx6#H`YqS#dbfcaKJNJ?m@ZueZYrubzpvK?`v=&&}3bymaB(c zx3ru-?<4`#U@6*CyNF<&saQCQx&6GuFy0<$Nl$cy6vg`^NL6zHp7){?<Z+bqMEgPR z^fLH^Z?Fey=o7os+~Uvgir$7DoSM!07D?+PX`B}~oi8~jS>yFp60YkdW6?jqucgS) zwCsbO>?gSamQPyC7EKEE={-im1){SKJ@Hw(ncL*$BVAopT`bpEnfpqu+Hakmn85fY z^1WWNY;rlkK<kZX2~FCdj?2GvAw&G{zyFvBq5=9yRci*<9Q`9W`V0M*d46FzIXQKm zan^3ouR!9twyq^CBHHPoekna=SZk1;;RW&OD78))*t5W!tvlfoR7zWu8=)QlELUl# z$}Vr_r~^Z|WS-Q4x6H4hLr(bj!S8X0^5tK^?9u-Z<gh@juysJs2%$yk5FnTf7WVoB z=W)7fY+r3rKeSC1b-C&I&Ahd0K3r}Ae@)hD{YDGyqk-m=KV^pE+jLJ?A#!HTKaW>v zEmcfP3zuyrF>a8eV8a<+V52;qI0S&%n5Gi*(SBL>5MAtw4-i|@JO$aop(IAn^G%rl z3@0wnCjy>d^I$*~C!5fJ!g`q<ddbwVV!r~K7-Vv72Qn?}bH8FZRr~w2Q#dzg$4f}~ zw-y^{sy=|_{hK~$=V=~#8V#Jm9T4{^%EO!7jtD^5oj|b0(v6vtRy?K6U^JSzbbNXC z!$W~TK0Djl-Gu|Qo~zUX`OurQ4cQVko#7@#^N%{IeB03NTAA!UxO!m70B^B#gD_Dh zbK=01_5H5iciEVn$w~?j?${kcTBXVD;+aFFH8X47AeqlyUX=tHB>W68O4mL<KL0|) z3WMehudwHNAbe);<Eq5g=EiH}(l;E{R3a0-fG=O{4&nO#ostC^jLg!l%}oe6g64j8 zso3`&!Sk|?X-dRY1-LNmJUja8iqKz@{j{f^h+H{3yqMDP-wHQ77dJnWcNcc)b1=%- zGRU)y^muIe5t3{Gw)`x&^2zz=oTH)`woIas!8a;^97K+*qXk8A;O+UpJZ$XDD2tf~ zY}oSa7*?5s(*x?P>J|~XgksI)R@m`Sdb|x(73}m)+;k>|)rCvT2isXv&8d@ykXSMP z3Ugi{XAqj|zL~n(Lkt?6H7CCCqudC15doC)HNnQ_k$~_-FTjHH`zxFM>BQ~>5mY+o zAG$Gq1e=d8p!Bm&Faj)HtscMP3|YNsP<yRegr7=xKGG53W@hzEi!3XgdYh<(m^=eA zllF}TM=i<i1wB!(%5#1~r@?qrYD%sCU7LSF(Zt0Cw<}cqe@ftTe@q)Dze=%XL!K^A z#3u}RM+u$_?LIf>b6SQ4>MV7V(_ou9&5wcJ4?C@IheBT&t=gsR^6@Br_%yGfzb$b^ zp}N51+}F=XWUzqGpRv-wp5iE-5m>==o(;UHoE&nH-8KRo3p7LNm8<Fc<R4(BK_`{_ zqOld+1-vUdEs!D8Yqam?ReA)tdG1G<=lf@9TDPp!ss~!dJt9Iht90+rg(kH~K?$sw z=sLK_wjQU|{K6gb2D|zb_9UuwbD6jc7_s{oC@P|(|AdJDV>-U5lCFrF0UTEQq<k;k z(SxX-2<#3nW%KFt-!iefC;8MAP+G&F;4h=N;gU^}q>~>s*MxHG>D){f?bC<95~Dy! z**fjy9_DJ0cG6ESAm18P_-fC10(PhrX9VsfwC+nyhP;aAJWtZZK>|=SuMSjowB*;M zzL#s%E<`_RCr1)#RVK=lyGxl1y-(9%NWzBF6hB&KAd=1bg3VH|N|eExpO>m@d#VmP z|A<N)LKCIPK+ARt4A5PyIKg^+X@n85n)?)42t4(@&a>uGBZK3GQK){U-+$UmOXR|J zY-lX*rn)Y3Y^aIaZ|hSjXV!-5QqVl;M6Q2|VzlmMeD+$mWR*I?3#CDiKVm`q$dpK6 zoarMSLD=jiWA|1jBuZAl+j-+AG$Oo8;@7vQbaSNhp)ltyW`^#vV}w>-xSKmjVx8zO zirnvW=WY33@1>mtl=q>8qo@hp&dYQ%2p5}5zrUU4%Uc8wjS9o<w%oXUQ}S0kfEiqx z<z~LMi0q?V&`?`YTDV%|w?&~HZ<1Z~iHit7@T#j0u~N3jHY~PViOi@r7I4+xV=cL0 zFnH?tHOk|3X@cF5_ERIo>?0~v=F6o%zCQ*sF1ij10(!5}<;T@enG9_rJ`A@(f;Vi0 zb7)g;YkiD+Ow^^G1CE0F_wICQ31(8gUn!05N6Wn4>;4ucF41O-Rr=JOyjHwJF{>s6 z7iQcyPsa0G2&J`!X2oP=IwzGA4ei0id>BE`tTd<Z@Y$6NF?Q$6344zR3|59vR#?NI z!Y$Uy!)3SuB16ZFzAILOXi$BDqsAsj`X%x)n|2%9l0W$?a?Yv*6mLyQmz>^?yd<XS zhRorIE~wEFeDg-)f2BYm(PV1iBX<9b*4Eb6$i@b>-$XUSbF!Ivizrm{N?qV>QmHM? zyY{ke0!-^joPm8~)icJvKlv$*5fFD!rdD$lxh_6a{3~izUKJZ%N$}y};s5pVA`8c_ zQt!)sbpNL-t$)%vNm;b}^t2{QhAzS?lL!rg)>I^YD%~}Y71XV*xE~+h;$246hbpP0 zyFDWdMS4k5D)goOYO&w$7`IYcdhNUk%#z0oS->>Ciwy}*XcNKVW(g&YK>+!}_0E7e zF~HX3vm0I=*l!q#XBuV?O&+o2O`xJ?9yq@#h$g14XM?YmR!prqLO~r>4%)EiUkn~_ zGe{|9g~>hGy1P6O6cjY7WSQXwWhueH_u0MlvdCzvi3Tzo0C^x^`+vg1eU}Fl7w212 z*Zx-8O>CgR9;R9oHAB%tnvUtY`wTg6_68JSr~vnqjCK4JHMwTQqt{4_cf2YP&{$h| zE+RCXXphl9{mf|^P+*7W`QIZWA|f8&fOH6>axNDktdh_!%D?C-dbf)U1b3aCv0<k_ z$m~P_=@%1Iuz9ASy^q>o*b}rDNM_)E_>7Gmz2&il2h$35Dm#pFo|=4b3&mQ!`=U>4 zO(Ty$q7O`Euph@4z&3xphzl@#FrPWP?f!Zs1Y!n-h<6|V*FS!g4jUaZ^-E}L9}KW5 zzb8>Nhu->YLB%f+vHfZarFl6*BErET;;A;H_&zJ6754i^CWzs2#6Q9?;3=gVNol69 zNHz|5W{fqfw}HFh|Jph4K~dJ!)Rf`@k#v7&4MOKKMoYNYf>R<O*>tB8k|g1tVQvff zCNt>aQq;l08=u*kg(pax2aTVq@Gi|YKeTV0f%4wh(YNC{|1qVFyyi<~1$m#PB^XE1 z3xO;pO%E=4x?7i2W0QCPB@wiBA0$HMn0z?xaa{EJ<+*Y=I0k>m8{-RDnZyrOH`oIF z25g7_H*?xZlhe-6(cqO!VNi^f^tAqoH^hTaL%&a?XSMf{2=7ff!jMIM&vHHNwrJ(I zPl*v-DQs3PX?~i-eRdg#HxEEnSz21MsraYBq>b#QA1UbI3{mxZcA}{=e^36%@mtia zULT0!z36<w5Tt+khdSQ1Z($LSED;Wt1>5995_;}`e8LT7N6X)J4G$0hU_i<UYO|tg z;<Lk(k5bHD=|d>EPxS(s!+%=WFB7@LXg!`)zAue&ScIUTw{oFLT9=wa7i%g|Ej2tl z#O((`<RE<YU=F`5w0b)b+9ZvSRN76S9Oj~rP?I%^m5RhOA+NB;ID@@ExB0pzvE~Yi zgJ)Y*Gkaj`><kU-EF34rN`{Vmv!}*zmQMq>$F|R_ebBC7O4C_;?Ctp*|NM^{5wlN@ zez#{V$3B~cKjoVc%T-iDu5c|av{XV&Z}KAe3C)SV4Exm)ou9BLy819N(DtV1A*fyk zIW}tONAQx)s8D^KcAW{YAWI`%>X$MjP+#T7e16?0T6w9%?nH?g?_zXW>N@?rAY=A% z<L>(zu0`mii0FwAP%-nP%1(T@=y_&u)m!YdY5F@GG5U^l|M(dWTI&&1(pI%(PrJdq z=~r?Os$}*r{GYuUP<n%EgD>HKILI#0Ja88l>B<3i3+!3V(R)76ay|t|N$d>vdZu&9 z(Kx;g{xBR3a|(gSAWLHl9+Gd+RW?<oksWKePA!(v2jC=GJdZy-JulLUjBE#gB-F?I zM=l#T{9C7+;S!!iQzLoxSyc~P087^P<#4~iDtWd-R%5+rqVQd&y-Lmut*?K*Z?fIL z!$LrPf*?;|QNKjy^<tG@C$aqlsd9!+F1Vk;L=7`GPSBW^;UtNq6E&Odyfu@#an2W5 z6QB%0P(%dYvHeJg2@jYvMS};y!JL9`Kqhsy-G>kuM#0^ix(902nyS^BAL^n~Q$~hg zCA!)TXxx#9eVu6Hdaa||vQRO!b-&u`feF-pRrHAlbBY43&pX5GwR4}$83hl(cmnQc z@<5e&jVnfSOjBK5!SXyC*Y~tL8thij2o{{=f<$#{gpC^@y7r+dXBztCu3|}nc+vgM z#~+zL1Zr^yOmFMP1vM0!cQSf%p!BC?qy;>*Bwe-no@ACXZ$%*MYX4zwkM^#XY1t!S z*)Pz>C`5Q>yXg{Uj2khbEgUypO7O$LT`CDwrj0{wu5FOShIPWMu&P2bw;Cm!dhlRP zUQs~$T}lvan^b3eP^2=N6j~?zNlP#ze(j4UQz{He_%45<U9KFfwfhGqJe2-X@CwiW ze)e@F<ujStp2T0<RHzig|K7z4W7~1WZh41fH^hOXV~+#giPCl@P!LuR)Gbh9+78Y$ z4IOPLT$P9FP`DPPd}YSFz~y^3CkjYfwZ07D8Q%VtM8M|!<!L73etSC6pHWZ$ZGkqN z8Ko8tY2#0wAr9ev@bdfAzBn`~%tf7PI;_-6O`AwH!WxS;aj~}HYXb~)9pv|o&CNsC z1fk9Icu_Lq?jx{RJLJR|;XTk60tO8&%?qT&>!T`_!M7wKrm8}BDCp_Y%M@x0Zl1Ym zgE71#N(o`41_g4sGc7?$RrOfSwFyi-U3W4uHhVQz%RO+>``}HRP?HW&8h*&`jm<s{ z<D_E{2x(0D+U~=r1oUR)s&pIGB%y3wEY;(CeU#Acdr%J#A(U_-UX#i_K+c$z;g5)U znh8<LcajuHlrwA7!H+VRaJR`FR}S&)JETChzsiBx&Uu6`RZVgV43S!q<W(FCv`8lK zn^At)RUpK6!6r_(<iSZ}NnBCrBg`JzD{f5~`~r&nk4Z+{V^L%>w+3*%go1Wr8BXci z;F3jIS@y9HoIGL3BE9;!%T)=IBg7j%u`))<OA3==lwkg5#j}$A-s+1(V{nLS?iHc# zZLokF=_QT*c>K5Z-&95aXA5<)p%L%Ib)3<rjUd0#T$KrWydr<2pnqs)=GQk>70t-b zt^kZNP-TA4Ycg>2WFuf*Z$}EWZCA6N+QL=F#)wmP`I*FX_Gc%tE8d;ps*P_uQ_cF< zA6+SCn*RIs13U>5upsFXW-Rw6VVrchX1AX7F4U20Fbj@m##QF($1A~$h)Wt!N=JQK z>?=Bf0xyG^JrYIj^;!u1m4^YS+6IUb4HCbQt~5jc$hEb#^5=OcGT?!1fj@wXe4-%( z0W?@(4&3f0DXUa#a{ASFKJIY}ii*DF&jNO7mn@}UNNwk{(4Xv=OR*PRP&hA=I%XmJ z?2yR-s!N=JI+}zJv|G8YeYxknei3`Qn>ZG}xxNNw#Kiig;megFxV@4SJDdS`sGyJo z1_DIR01!`-aaX`_EYPfsF(?D>(Q2~`A`msmiZgA0g-TLD4dBEjNT1059{Tin`H;q_ zLHA2AcCz1uEbq0gy{T+uJ%yAyu#+>HO5%d-7eVE~z7M{}4GnC~QQ^j!skynib+;{l z*6s7d+0-ZAMGv{q(!Zkr?8uLR$RJ`;Y(76dB%Fb&EtSeSQ0@op^Q9%?f9%boo|suU zxElgq>F5sxYH}hE6dwP}Kmk0KWc<I9t~;K}w~Zfr?_G96vXY`~QFb;*_9#cPvLmub zcD9HldmJk}TiKgbIAlaw86n>5{NB&UKgC(kxu5&GuJ5|{L7oT(3eE8z2Vc;$RZQDN zvSHDkob%Jws%MjX%?Xd0ZhC?+bm#Q%Q;<Qg&@NW!0e}Tp<$klz9w8P)uWD#u@p(P= za_hwLYk2o9dqG~LnXrSbo0&x2N57dP2J7L-FWiP<E5YL*Zgud!Q-1g*vtiZ|gGFcG zj#|=LgJ`4dK|NM>s$Vjh@Zt!-D(KOaTjx&>w}37HPl<AVi#ZjCtY6wsKfsa!w+7w_ zex#g)Q-hZzVY)qg@n|?(^PkUOEy|37Yq{jl*a8#6GNhpiJUW<j2FfZOIRDF@ui-%; zfCcmm+)&%oZD$NIcWsJ?vu<`oIdy(DQdLaR`3m<tS1(tHF}qbs<p+?t1I2ei@3koj zPc}TWp44yVcygt(tYDCFaXdf5d94*r@*ngLoJp)1ADF%D#kV(Suh%u6|46mKQfDyb z#x&<VnJb)haNND2Nmus-<UtGD+tf`>O>`A)TPH^VthyflHakdL^?~^Ti>A{Z0{Ep} zq@SklG)1xjXa|szdQE>o;C*m#u;9a<$#ee0;*0<~XuF)rLw{@;?T%(LMj~l$=SBej zoWj{j)E^v_X`3M|M5~NHU^X3NAR4?i_&i!lQAB`DAahV{5;vqR*XMjgtV`zM;@TSV zzVGXA;W_>XEPydbCy<zri(@AXs(>RrxMpP3zqhvHTIP3lt^pxiEs2Fi@Fd%Y?`vrR z?jRz==Y}(KAHK$+AxiA54G;p3q%%=fTU)?o^+Zz}ES*<9oAuKf7*4DM%V76t#|EmX zv#m8qEkK3XxzroCBXYhgauj!d6c_6LU{o>{$@*T?H;rhyE-F~GGbr+2)`?TZrZ-lc zcz^oxq0)o^K&mMU`I7GqOR7$W#WnqQx|tFx$_z{X>u8;3K^`2d+3Nhg*(zxdd&fho z4*YM&p?_&<=>oRt*ll2pR`xslySjNa_%Ml}{(Y#RjpjIz@KyEoUh7AIbpaCd%?SDi zI-wNUW=7;s@<3krAcVloAgL!u1(P)r-1VBD9_6#mC+h5O_6V0&0{DVP1DS6#rxdcx z<oW#euW-P4=Ttu%)7H}Bzdny`kdc>Tmp-Be53+zDUI6I^b|gW31j1+rAtfpK!SE}} z%PX~vA3Q(r$J39U{pTN*3z($02As1G=g3j{3xPr#`;4wISiY^Az8y<<Tpe*}(l(#t zYpo^lqNnbO6)a-^vY=^`2%g0LyCFF_^=`Im)0Za;+!d!i78}dOtI~O(N)DL(@ee4j z=~2uDLuWtl-S3IdV5B@@7f2=rNe2!BIvl9{n`ho92fu0!S7sekg!KDD#rho%0saKS z0oL<~2U~@CO9$v_>4vMTs}pe4e{1;)rmdX7UI7452z!aC@Vx^5_Y4A*%LzqjR>?Mz zwX<lt#(8B1VqC72d!{9xI#m&VvLde^M2J}C1=va5L#Bl<aImt9a?Q`qT5YLbBWt9Z zZ}orn%f>AAUK~4D&Tw`25X_3{lk_)fv@%$mz6oE5Aw0PSGdDP--j1dT^d#~2IW(72 zJWIUNDDRzX&#GN1U49|1Qh6r=iteQe4>M!V2g_B$mwskE6k|aRT|*UeXOyB1OFY)+ z0f2p2HZqU5+5uwEXMQ#|Hp^RZIWir;{5nY1GcFP$1cN7HAe+}*ICTeZZ(K=^d~goV zRzJ!mBP%X{|AMtyE6nPRHvbzCEP=p7U{ASJe3k5$U3cBDs~$=pNd43r`S02a{B}3H zFyRU?k`;1FpP_Fr%&>V`mdyNqE2-hcemomct3~>-jU%$AA4$eucO7A4XGc%IM`dg_ zJ%{4dw*i~LT?YliV4Mf9|F*_~^wRVS-8#Ut1OJi%*FTsv=o$X~TR1i@ZU=Bh1k<6i z3pdPr@NI(Cbwr$^?cc$i%17z7lrM90oj|hky9aaXY}>4&wHj5gscSrB?~U4!iY?Y- zK{EFKRZuP%Pq2`>(&HDLwcp;v=5EG8@<;eOhyR~Zm8HH)j1*>jJ6^K}h60dq-l;MU zu|rl0KJMyrx|w-NPN7{^RyOvfo^nh$7H@`njpHIL55}Qs^FnFABWiF)(c#Ty6vRwa zi)Ri-PNQ^o{N-vmg+ISbedS`3a@p}a-D?y9zF?f>xX055RzT_;=VE14G=yLB1g%K> zNgY?Vd@V}u&XZ7`5Rb8w50x=0GsH?Z0f1zz0NCN9i)0tDg;jNRJs}6M$%$CB+A6yN zm|i_Bi|69xp}oU-ex|V2%K6wkAV5ys<vEyStgDFU4_!Sy3BhXu{|pYsJ78}xbt)ag z2mo?{#rn?=&(G!~$#TAZGXsH1-mIhIxOlVgK56XI0!-F$8Sq?MN&U~y{;6y}N<f!^ zt9QmmEdK`~xWPc))mK%>_0V+7zsS3bgu;-9<*s<VP(SU#kDYBZgBN68mc}_H`Hyv# zT*2oO92{KLTRkiInED7dOyiVp-X7TUs|vy<X5x7%(WI@XyXugSW9UzrWpNI;D_|WV zl!c%wDV`V}8XB<|S0wutw5lQV-g|x*cG&l7LMxH98Ssa|WY9vDkMX03B(LQg6{o;L z6)ol*4O&po7-$_b!a#U1>>exD*&DpJH~J{K+fG^7fQIZ&udDUH0mwCQAEK1`Vt2Mu zlK9CMcFa3ckwX{JZi&=!mR~Pz`k)oVI=KY<O;k35Eq21X0|NK9BH4#m_m1iVP9(5Y ziafa9z!vY@@;z<t{<t_;0ziu^CNVTRN&~kuRA^xCKz_l#t_G3>xS%#Tt0rYV8J9t< z3WDgWpB}d*TKSR-P_U}K|E!G~H}lLWWSq;j%;yKq!nXp(pBD_b!eEZu8I+h`IN53g z8$}nq;PJmR73%cU^Y)Ebo&~jeiJN(3FCPYX4|>$gd=RiPlF-@{fv$INC$r#|!Bniu z(f2#e4CsjRtD57+&rkAkl*&1{L4lp=+^PT{;OtD+agM!e2-Hn*L#Dlh>i`B}<FHY? zmb~9z8#+mEocxnjuLSKmG?Tfj(7$U7rvg+G)5rOYH_2+4KmrFOuAn)$CBfB^etznm zA0fW^9X`_0Ue5W;&dxih(7NbZyssGt|M<mFs(2z*bPj_H`v_Alj{d~I+f=+x*B2#+ zA})PD{5NB&UiakftRv_eqz*)q4?;fppLkkJP`CNMV-PIMI?91I56oM_6P!7G;Ea@H z6&@tKXVmS=x^iMZu>oxtKxojMrZe<4Ioi%tvr2l6LviF{$pI%N(9tP2qcRKyL^g?R zkLd2`TGMp9+IEBzOe@iDK4lPe$eTBqG7txSoUx$rW{9K+v}hXRrZXE|&bKax33e<> z##UP15|Up+yGaj6`)!RlTj^%TY{**_6k<ML85stNHUV5=UDCgq4<`Ni>63EPDBBRx zr1X4O-iWV8{LCjawM~ms@_wG#m$DA#gHhG=HlL$O-5Jdr%;c{E`RVBvR%0r^;n}H9 zGc@FkSc*o}a$$HBD?GExt{)wcy6Oosf?MrXl3Q9@pXKE%^kUX?H|GYflC#uhURIhw z)0A)YJoFg3>#5U0e}&zZfo%6l%~IG++(eY_ikXpL0Yo??=q}TfvOvu%Wss43Xd8@S z%Nu*>KOd)AC_I-g`X^m-QYvQ{E-L9<XOq5wD$UE4!-^Fo5@{8uHyEy%`yue)3d!R( zVLdBO7xq*dz_49`w`R-y;hK&^!y-z>C^blz;EH9OL3r2o<+{etr^>us6u6v!H$_7g zZ$j}=sS*;QwIW0F1pg>AR9B%9cM)cHb#|3fjpzH~?|OqtTvHFk{b%fArE7mnuRd=h zJl-v;4RN#R?&RM0B1QM*459@Mn-pzAd$dh|@0ZAXnB|G(m`=wGcI2L*5=E6it7W;y z0UGMrBqUgAoAJ=POf7J2;?bFF>o5qC%?sHG0WS4l&t3#^G?u6_&eek(;J(~H_w{Qq zkR2c2W|w;|y?eB{$aQddc<+FD_+d3HWaz}Kp5+n^Cx71pB}f9Ey|XhFXx%_TJ5}}o zt5ie0F|Vi=vIDGBV&3oiLH)QX-C1>dK_ry=Du<`@htNUB)jQ9l%TVjpPn?~@&HKd_ zpN$i3ybVGcihU^M7rx}Md2sS?rqyP6PdMg^W;4e1_b&w&Gj3t`BK;14N(U%5F+p*z z+nRJJJ6J8&8zL$AFbJt~swn}!ZQkM=I{)vt*CkweGm^Zs6yl9Hr}b_Bp5#|=!ti8h zXqZ-Ugq5X1slV&1zwawEb!kJ)OTOtFc=JOx8ZVL2K{9}w8?M<jGBaV}8T<h#$`Fel za;K{Ro&ktmzJU&%2CA{QoD4B#H?t>=Efd(539Ap<UhLGLzvmZb;4)8X`L!%DzrPp= zQaVb~0X~K;1n-BkwXo=Z)|;Ao#*Fl3Q;F_o%HJmZK4uyDK7ZG&0w_1k#?N6k`mLim zzP<ZH9fUn@#ce+V3V+B!1KXQHcH9~V?j~>n)S;3<8;L|Jz-&Axuhxa1kL7AXI=Oir ze^Kti%}l$Tt){KkPGIz<hINa&WG@B?1^nBehIcp7D{$n$&5hfakgYB=rsN~R+8f!D z`LR|oPJ_4e0McNf-I;VCnR^QAd4@8>t2=}t6R6a4>$MB`X2#rm-OBQ1V{IT>sk6=_ zuLjZI!-MnVjS8ES$1pzW7KOmB51U5puWKK-9?1){9sh&+d9+zQ49*MWx8WwQt!)s< zLD!Cf<r7r?kpM;}EZ7{up!Q@6DqLk#Q-3**<{NOqz@!ICBxp#1sO}eO>+0nQ80c{* zne2dF8auuIUCmLqd165zd(eAWg;`>kRyAQ;zD<9_yy=^hV{hK`XQ?gc?-JQoSG4tm zxvUlWrVSD`9458!UN}#(uTmu9FO!mL*@LJZ7!8o(A%^&33QLnw84d?0{&fU}8;w|+ z^#Kj2=|SCxeZxy}au26o+IN&veDQFSWX;nT2z`F$*)+(=Qh<t$5cIDD5zK@`ZPmZI zap;&zak%fh+|SBCF{a65DwyCGr!3HXm0_RSg$%h9Y^eWG2l}e=jj<Y3+~;PDrrYkI z2q_d{H#tHFKNZY{$*iXtf-;c9;4$#S?PxKa!^PWM((|j2BZri;{SNgH;gyRHklfPt z@0T-3CT%JjJT?rAZP-pFU-$e}5u4Mny`;2YYJ5Fiqm-;4wQ?4jau3w!&lG!eH$xSz zc-ELCU~7k!+22WdX)QYny~$7Enot^7Xh;M%GR(2%8)8O!GXh`<S^z--rI{=Aq(Jk4 z_#N7Ho2O5cYKx&6JnFYN3xf3s9%StG0T)xG-Mm@&MF}Tx5>{RFad!`aRtsys0kRBm zR1oO89&UQT8_xYCe%hhNn`!`RFhgeItfo@T*NgsN<WKbz3O-=iYmOO{k^0b(gVLw# z@Fo?lJE%AO?F9Ql(QHDDlgqZ1Ef*l`wg(Ky&C!$AxWU1}s$umP`$iS!RgXKbgG2~` z5Nn|45Lo%#oALEG(;L?mAQ2%vJly;6H#EGxD=WP4<f+9Uz&q8A)!D&(Q}-ad00kuh zez3G03<i}zZw<?xOM4t*!-hPYHl>!DRtY7ZCvs_1X5C+T6~PtMZb?1Y@hIb%5;Io9 z;Z>68crzNjdrD9);p>I<n;_L5$;o`exe+|v%*{3G;cxX*wr_HGZ&)r)TFdA76~m1y zfrGsvynwhw2Am%T^kQ{3XaAo4@oh=qzK*~uV4!~jP31<FFBNxrs2;rzs!19v*{Cw^ z0?6yLZs>Y-Lf_fx-Y|@BM>*$v0R=z5=aOGFF~$GZ@X_Av-dMCexD<V|MGyJxUiR1_ zhjf``lf9uX&G34C$uOMguFx02MkPeuqjs~{5f-{2sPFOJsa|jWAxZGz+@7|f{>&<m zl@CyD8!4<jYq6#J0!MJigN7V{1MuBwX=xdcq~rbs)yKd$<&piIhdZy7f?J_shuN48 zl-Qs(>Fw>sev&3(KnZ~c1dwabcrm|JGPd*d@5c|53UQyTT@kB>;eBkvpm3Q}zD5Z~ z@`>rgF{4q{9+tyOLG`qaVv{lqB)X*7UH-{LX~NMQIP&v&bMs|cC6j_Q|F;8>bc9+l z!-N2xs7=Q<tN^XkmtdNO4d(hhAM7XaQ}6T**QM3><o}2pU`Cd6Hv#X1`9enb>e$vH zA0t|K`*{kE#C>o+fT$Y8mN0ceIfO1&{n^kDH@L(d!yCog3vwSm=~b5BVT9P0_v7t@ znG&IFPeqx#esX8>DyOFXoO{E%E4g51lm`>qn5`Mks1(KS6cHl#6M?G>%pskkE?eJa zHa9mHN-A2dWlzj2AXg(6#kUT~-t1Jqu^@JL955g>J@O?B#eKl<(PReM2;r^wpiaFX zw`#jjDmwIRbbt3pzqSrce^_Aj&-ne>)yT){>ppkL`cx;E(F(O}d5pO!c2=OF^=w97 zpDLh@GZkja^d32h)=&jd1t!OiFCF*q7$^Hw(y&rg*4&Xm0pJ6$=M4J*R5WWLsXSP~ zjy7)=sLU?TAMSKg$t?hg$agk;{#OpJ#HHy$$N&H_<F|1?*Xe_Uo7Ilunw{Uje>cu> zhUOfKB+>BROvC&K*Gs!ukZ+{kL@70o1Uw9jQ*NiWof5#3DIH0H4FQ4$Y*T*y{P}aB zml|$08<|q#=T;2v$jd?k&>h*o$ug<!+HWwMe*GyZ`)6{VsZpM(D!t^E`|Ze;j=gJ? zvff(LX~V6!Lz<rfMP7N1#D|+Z9H!_>!#@DA^qwDEoIl?5pmz*7{)4j5zyM<0(}TUK z9s|x+y;%mGIk?Y3SZ1iMC{wg{?Cj`RnU)Dk8)#^<Wu6g&Ebo`+&ae7;^q*-+%YxT{ zm3wvur7hu_6ln1PcJ$cHg2b)5`uaz)_fFwv6b#-3{0`KEz2FIh(hRv3GBS2WZ_76@ z!0o56|4RxC3C0iF+PmBLDuXO5paS>>x<~Ve;B70lBlJrRL#3HK#5fuxLfw3;q{F)a z9T_;u<GmN8i<g8&Gkor>7Xj)HCYnY}T?y#KutL()HW*}$HG5zW>HU&KJ<)oJ*uwQ> zr<ddHs6AF=yZQWy_yv3M3!7=~npcxafz}q^H3zT`90OQi2ee0kGs7+Ylwl_zU}H)M zMq{2oe!Q>uTqK)y$)ktL#@8zcis5P<G#8*WBxV2o(F3vSkRN#h<Yzo@lQR_TzobWX z6?>b4Rjh+Chdv;B-0nt+t7x@%mq!)Aj*Z`hKmS0-8-NTKVq_xq>0B$Hkccp!-l!5J z18u(nLsydSRWDCZEOb2KaARs@3(_SVMHm}}|D210*`<WnA=tcR5`cS-3-v$&!qSH? z>G|3%Pzx~wz6BrShjFRuv<K+ifCH3pb1OKII(<Y%TXXlweU2Z#pB?NqVn_9|n+kz1 zr#Fs6U4-?ceZNO*qV!rhM)k|jH@p$tb8R3oG-jGA-xxCW{s0kyM`FVGjh3$O-a#dV z_SG5!YK#qaAS<YTF4;OhVPB#As!7A{FX^>gCS3VNAB0ea4lI&6rjJ-6{)>?_ybr-Z zQnA6HNiN>AyItQ8Hx}%<j&J#B+bv7)gPCB#FD@Nw3G$-2Gk>)3&=5|F-Jt1Zx!NBJ z7AB_Han&-TU?k85s@3{B3Iiped0zpyW9u(YmPH9DCXy$4q^6Pa+G?dQm1>`+vPHQP zdJ!*^RNqeIjr{ZV0~y}m4;z95e+EN5f?x9iWMO|dp#TM<;1==DdlL=YBOe$4eYBc2 zik^_vsghO#v(q`97?KU_5%#{qE>1UkS<>F$u0~dDX<)dlgZYc?nYy8Zg5%OU?&<Mv z(AoFO0lEv8gAbqP9#$9%vcK|{m-TF{ztT?{KW;{lMRYVhJsrv>@|z9sMKqh{BfFKh zh-yn}$@!W{k=Ce-yH@>%flPHRy#KVR1b;TGBb7))H$krt+}lm$jx~*Ay|A7q17JH5 zjxCf~Tl@|g4;{-@zZ|U#Y>B+~j_-)7!3SeD%<AvZvff88ddP@n?(Kk6<RSl1`-Ac3 zNt}>+&V=@#Z=BjDYLOGymcDC>e<)cO95^*{Z7PS!79#GHZMebSq>JQeJ`phX8IoXF z{?{ok%0S9OVwkXZPWbN^`i307@Uc_mSF(t)%B33jBl<&RgPXvetf_ERhLO{}i-9e? zI4=pY%-uGL%)wB5i#%iLzS@kzhoA+!d$1Ajk;Df;HSFSKpbcH!NiNaa92f2X&Sqt* z3U46diSyG1>B~X@RmAuMCWR8K2pk;{prw`#9C~DJC&t3)CoL;$h~plr4;$4~DhacP zj!t|Q6Xk5sG6+{75+cS!n@))aX=wCAEkO0f7fHS%82%u0#hp8UpkyI?1l?XSb#!#^ zW~RY^Pm-w#5?Ju)tvF}~yJ)wGjv5j@xpDCk#juB|;l~?a4PA+bKGpM!9e`H`@@`-v z0z=F=t>fI#177{t>WqC5aMTl|D<}fRJjE_1e_87is~GSkmds(=C7rHW#`PKRY!K6Q z>sO=i38zMXYja`_8_A$Tlv+P1Sn#PlyL2So-h{{6tHT(<eXY>PChftpSo9r6cql7q zX&22{!RaZMuCa+UoMza#8cpQ?$(2;4*2L~XWfUND`U1&)nt`^Q#?s8mFDBu8r%~wM zg;Jg|xY8%{xt=SMmI$d7421^NhW0mZJ?XxxSXsV7+hn-J+$n19VFo4@=_gE*OhRVU z@4j>-5t<p-k6Jz>I<x3U=f^GbGZM|!&2~h$F})bQKsoDcNPw!JgQQ8tjoXN8hD15d zE^Oudl<s<6EEDC1B`#)0-lV?(F9##ttD*`WT*Y*wn%b)r-#wek@Rew!VwXf0-@GzC zGpA@QS@UVMu){z*1ZGwHaaCZJ9BQpAF0+smK2^nOtvl`d7Ogfe4&%n+6xKJAs~s=A z%{qA=l}&b2M;|wjS|QB^U(apbb+(Ms;SM+KRCymIPmE{lWa>YR)wXqixH|M=	?q zt6By2xpyxwn(}h$D2J%Y)4NjBQ(D%1vZ`F<3Z#j?!;8JnK&EJYO_CM<ud9yQ_azVS zY_O{xd_p3B@mH6^e;FLKvXPH7!l1n`3SNZ|mZv}vR`c_5{T%FEs@Z7k7nM(*b={__ zGgv(rd8#I?px`!oQm-BKBI3_ip9ahdH+y4wuZb&Vme(BE$~$a0#0ZC9Y}1=R4<s~t zE*=jcC|ol4saM?K3=9RmDoq0CW9(qxmzJn*oRX{(pB2mYD4z9$@5Vm=%C1(vtt(+F zs<(#6a1i*LHTT<N`z;ufIlPeP?~RGi)9jG&IgP-$7?;1aOU5LNy2Q+y%JAi<hmFmh zqoJ4XY)cx$@1{2P=nES@X@6}Z$6c)gdp7@9XP}6I5P<ZM5O%K%N0S%jB!itwEQN?& zTE8R+amjPZ!Tt5i^O=$)RL7YXM#qYw3QxNl$-q5|Vz3XHQ>%Eexjr)Pz9uoO2_X#} zsuTW*)uJY`#)`l-Ax9sMr<H~A)||0RDu2`I-9W*cPQXqc5#CUI^i2EiC}OCr(7YlX z2FJzTf|UwaM(*QQP{*%ici))Kx<xZ}4}u-KjuFBRa<=Xk1WO!#eCEe8rL?bot$;sJ zLyqUghx@h+`5KI3f~g|#mgWc$ZbW@J3*dAEgf&`bg&INvp^(i4?*Sray)$Sat(5<Z z*WeH*tAY|Nh40)gcJRL$C5?H%i2n20x20UyhrtHIIT@KwTe{wV*>$?wdUU_X-8362 zK8VABZd>eis-F|pyzaRJYX7Q<*nt)kzTbHY3mwNQ0^Y*aCjx?mVpn2P>+g!Clfzt~ ze@B@B^l8{2M(9;$9dDw45F@$&q&b#Nc5-@6V%uhpOlvk*vR>)N)w|cccBrl3>fw&9 zyVK4d*~7^A{{8#0@-Y#*czz~E5;X|>107GMn$RtJW+LIz@i(H|hq@Qg#enWZ7FxIx zx+HK<a}I1Vo8@R9_u&}s6j>bX8We{KGaj}R?h;!t_bv5MU}L1dNVfCfkPr_4g?y+@ zXzJd+y@VprrQra`A0}Yf)acW8V{QfizCZgQ7T@ROKnRTN+nWLlUonx-Qsoh)m+qNj zm@~b*TE;nDTCFB5MXA5n;Y-&0!Jkd+#PQ>h$PS;BtzXL!?TdL=8vo-B1aX>I#5EKh zy)3qz7eC9!7&V|9OZ$FgDM4*}9uwVdy~#K%{U9NPCfa*Mlfi2<xHT|8>Kok2Sg3P{ z$~uD?ic1oKoa(aMt(Bg{YyBG6IAHt$)DBRdCZ@ohptj0lSIcIj<kAj>O#LU2vgZ~X zbNA9gko>||-#`9xhA*N9r}9(UTq}%=Ttp)m8lb`T*(;dXp|UiUKPqKz#eMB;(=i=3 z@_h~3N+cJV3XB`@^k7GueJq{YAM>Fo$c%K2UG<I&t7MLLTx=|m;k@p_d}l`>0Rb5I zz{V$=smYEGW(>8<zh?k*fNe~}aSK$L{QjqS#KT<}f{;?m=Rpg>xywDehJ6@$f;=k) z*JFv#n%(c0aCgaw5Fq+!7k#NT=7zHX_Ow|?L*p<5hi*K{dfBi}tIZ^V^gz|JvvKNk z&b-q2g*}~F<7NiklF58V4P|k-QTKh>zy?+7Pa$-n?*hd`J%a1SW1OtZCR*G=Op;!y zvvopB9{{oTxKGV(xbr4i?W@5mqOz(AryouGfM&kYgY2WvXu@WQ$L*NUU8HA}rp8<# zvw)Pv8^#(mFv%_~Smx_y#d(AZT?iaiiWbku>_VRc6$>Wa>omE0{v7ZgK$UQ?q{sC> zVwmOG^Vg`1(t5S8y{gv3RN275yq(@qWs~RY2+C;BHns{4QMk&JIfy1vUHLGKwwb#L zc?lOemh&)eHYW677SMitsWj{kWL$jd!A?;LzLbd4EY9DYw+5^CW~@Cggz!9C-Sgp^ zBJ#hbN<z#bf4@IW-Ouk*DLaqDQ;yagnw+T;440ci<_$Iu4jWPCkb`%a$VQ^06TJ9( zDPbxOPNtRyI)&fGF)=YSaSTROCHx^XIXvvKOI^ih83nnJdGzh0lEiI1VO*FM0fysC zoV<25qo9ghz`=>d;19z<4M<{Jx4^jobPwD@533^$OG4G&B}NUmMS$`Mz%juy%acEr z2^PkrP8WnIf(}+;AdV#Hd$AW<8lmR)rQ(iKX|S;?>#9cA3)bEjt3HanNxm(Vy{>?~ z(4AEAzP<x^A1GVsuBTSuM&dbN852(UQvtISB>v3GBr1h~G#ZdQU}j&<e0H7%|MM@; zOtSz>@n`{N1l62{bn!(elMp-vs1!hB;pyc?Cl!A8EzYzDQd(RK#4(`gzdcP%M}-)8 zO;aDJ-L5B`s_s7N>g^3N-mbusPmoKSBZA8Bnk%h&G)Iisv<IM0`k}Fc*UNaEz#EiD zBZx~u*oNxl{r<1--0Taw$Qvt&*g6FD2R4Yw^C#p%D6TX;Z=>oI4S8;!i{aY66Lq@F z`yY*~;!Yrb)k--~L7j)6S%C(k=~yX|Np<l2in;Lu(HeLC<{!y=%+=i^l~TnI;-;xZ zj&mDd{OF_R1j&pO7`{|wl`;JoBm#gvSNL>~l!0m?51AU_jsK$xLRI)~6N8Y50u*5$ zZ_ScLB^X72f6klMdK`&Zt*TVa<@D=*<tf-7w9z<_m}6^4ts^XO_E-GD#X#wD>z^t@ z9cGusSgDmhc*pN^sa>I>q{K$&)YdYbRONxS1IR_qvOsuDS-&;6&J+pq5&%2!Q-CT) zJOel$0JnCJZ#2^r`5zDS<-NaTu$bQkr89_4JUu;WTf#y10_DMFZ$*=9FB8Hpg<3<R zi8u{~uC)34`^${JDO-{(rKpKuP-Qjke$B8SKub$Y6ZDfDYF6s^GUXtvxr4qNc?d}` zGb&X+3|481j)H+oQdYJHN(vnVr5Z)KzCg!UgLDnvgi>8jt{p_z@j9SY8~N$WAvMIf zxEhQ!=JH3=VRnxQINtXD?dAXBixf;0Z6~gaBZ+>rJsgg_(0+&%UxWx9MF>XzV+w$C zE-EA5aBI0;)y<TLmp$(~e&J*LF{ii6G}H@jG>f3#k^4luxHzsUGaQV{5G4<8Xc)V_ zxC3ne?wyd@0lp4vruJU`&L=bk!O%ZJa|*EcT};t}K09YIfts#@TXt6$9`#0R9(e?a z!e)3yA)EjUR>EhdlS}ovRwT=F6yWP`Y#Ksbt-t2=`OD{r)fy^C|9EUee|rt@!I}e$ z^~P;Yq02$59Q)7oSM<~DsnHfWNX|vzD^+YI<k_R10)k8Ow3{ltv#V@JsT+^F<PEw@ zZp{=@u<EH9iHGyw+}3oono`+5Ki$%aBO<smBYPS4{e6chz<<)^y|a50ae)V}&R03j zyD&rWO<So_Hh2|n5>wfxdUd%tu^j^v8`v(5O`LRZuvzgyc%KMiw;VP$i%gs*dbtCW z-t)TqJm5l!&Dh@QMh81pW8m6(yD-{ny`+8b{n9oAco+LpU*nxiB5D4xpZe^qvL4i@ zdNgbLCZ_X=bxRg9BlWZJRAg0#l0JANtRKq-MjzeN70U2Yk%wGUY4+>Rklt?pLW4G{ z@{pJ=Y2ievMfT?nBQAU8QJas;B+n8FXbv(KHae8>jQth?2mwqcxdVgf>b;d%x=wtD z6c!3mAl$+E5dnzRq&Z+3AhFco@^Ou63Wh9%Xy$L53|K&t)_MAa7cA&*ej4^2s@9^^ zsmNK`shr*pQoyJSHrgYY2m)2#FVW^*mn_PZm(5A#4K;(r#j@h%5)HQppE)N!^JunI zKID?*3a?$K(%RLrR*avdIrvgJO|*EP&GdfZguv);vjKGvPc--D79YvoiLH;JE`peU zirW1NAnq_G_0ps*i%Yck^JW=~AmWsb4s;4~YY+@&AvYEGNV0p=r7#iloZ(ueuN+!m zn2ze~?*hK`dIKtgHmdM2Q(3V5sW~dB=gyeWJxIxO_45-Hl-KHg#nmJbp^w3d@Upm( zsR{kJH=Z)K?}PXml!iNv#?lw=0-9H<Tm9ERxh?W-l^AoSA~{lJ2$h`wHm>C<6zzjI z-zE|u4gkk;aop0;B|$2D?5d`v|3!c1coW>FA|SHsvtjDCqmvPR?LoUgV-R;M67_o& z)oq>8(r%Y2j|m)`u~X;1g$)_aO-}$zJ3)e$bf7Jow2<!M7@M~b>>2=pRcd#KREtJG zyV}RIllG{`AfBHLgI-PHT9cuGX>@D84G^~s{ln(90Ree)F9xo6-iS~9<$d49#}!LN zVdA?spJbUBDjJif(OHuxriw3o2gx)FSW`;W)udX@@+S$|Bqs9xYvtVe3{<0KbL(m7 z8qvkvl;4PuT3;<PBM*=6ZaWO;hEhC__-|VcdKeA?z*Mx!QyuU(QZU&;B<fhO=y{W< zAx#S;`wSYK1Z~Vm{5MWoX=ABj)@m^);+*e@2*GAk+23dyaHV7~>c&&)*}8Y7nR|Sv zAQZzHJvGL2Wra&jjlLXM$u3~j@}r#*p9XQz1_}2SeX*}#ZQ{|#&snoh&N=U(nVUwa zb}#kaB=P;R4z8z=RmFfwctsVDuT;RT$o`{8H=hD@FYfO6ouc__F?@x@+U{Sk3%OEe z_v^{=fQ5anamWN2t@{I<HK4`^kyqus9X3qioh}K4-gZ<g&_^L52?I<}31gZBrt~<x z38LdLbU&7JV=1bXj>zB5zG86SkSrwVRDUhay~H6jDEw<FEQh%pFM70TAqZIJ9(@rl zmAv1Hr=lkgj_|>4n9HL)_ecpm)Sw;EWmwqd8TAa2^UT1oN*B>l?Nab2e7=jzecQvC z(fg`Wk7d5~tv6;;GQ&y7vKj(CIQb7G1L>oP;vHSUl3(RHfF>n&u?k&lEEhx5E9NT2 z0_zTZl$z8v-t}TNn?Pw50l2`1wV8vwPyz&!qb87Aem}}T_vv;~OmeO_#safd<bR}U z!Yc1eio=(H{R)K&;cLZyVsrVxQH7S)R^c)-g#;~cBh)+Hb_m28doNB?yabR@WYz{Z zF;To5JZ4b;&24Iy2k9XS?dr&<n+)s0ruwLc_x7Vk0?D*2)u_Qz(o6do*rZLTH8;s} z4Kz&fL%i&S9+!A^?=RXopqmPbql|ckSWy%p3u*DD&hz3VXK$KyRGW_RqIDmlrvhIN znd_aam{4ciG{RJ&1owjc0V!Pb<WL$aP9zRjn={B7Zsy34o5c8Jzb7SUNQk%h&JM4w zt<^Wi3h4l<)D-+)Pn>RGCHTsl=ig<3GEK_DKt=-npFTmCw}euL;j1X`+z;#oodDSB zRf|G<2X<<R9IM(%vo$mM3Xem$rG<ln1cJub2*vh-#^PVNH(aLQ3uU61-MG>5rg&%3 zCC!*KAR!dbSbhf~tm~a5P6xdF@xRtrLf_uM(C|k@5->Ah7QdyT=qW743DcMVim0hM z(UV<a4`h9Jj#B?Y5iCfPp#i89<7ej*NeeE>qFJdEOjd)0m?Am~`Rr?{`c$$BkpGQv zBPMPmRom<8Z@LD%|2uW9Yf+G@EF+`*ejFKLY&7=5)jGl97H`!E2B)v8>=K8{{geF1 zbD+Msndt&pG*ni-t5N;LZPc&|3-twxTKViQSVaFW#vr|S5X)DFVAaECuXUwnck_r> z?-wO6nwu1n;yJE+(zz$CFTn7s|HnkMEb>4szfUB%B<j(XF1+}A^03Hmzt+OEH~WAp zC1a4+beh0L*8B61-7#`*q$?{C*iusLuD!VLYbSfZa9d>kh_KSH14l5y8?k%DjY4aR z#fS>`UB>vZ2ky1MS~0%=1!`)GxpNmc&!}-oD5(og2>?dA#!YBJ4drUsvHNJ0`1n7j z9?07<FHfPLTm)S;0vpT-J?aN<kqaOqn%{~e>EZ6l^Ol(G>A8!^ab^*sv|fid`}iHF zx|VMiwCd2H_M_9{0X2tK9=gvX{P>&DkS|GAKFRDB1sgb=OtH3cWzPC>I()ObaCvB{ zTQ3+AUYFJ?{8=~mD^|k~xV8cmh^a(2m%R+uGC(@P+x4*lk#srrj|?kcbEZ*BT8teM z!P(uw(>)eN#F-ihh0>=0C0xot3j$~3lpC~sIDw|Z^))G~BWO-{GYfCHw+OoJ&;zMg z*t}@fg<yr0X#}lS3mU30^YET_sm{kir=>~vUo@^(ROE8wcFK&UsAY`I3$iLva+`Ht zfiJv_f%;4GB)tUcBJ6>FxVWZ&dJ<9H$@t<8jphA<%0N&ZzT3D>mjv0qyynQ(d%(c> zutSdnu>+Q(fg6q3(+Nx<0eVkDQ?K|$l*F6NG)1FD+1bsT;r~Y{5F@$DH+a9hDe&4W zda{Q9;<?34o>qvk>;N4dIOtDsKAu5Jbo?jVnGF*2LjQRVDOolU7FkQOb{zhz;xJz( zj`{_;#rPsgpH!Z<U8dlei~saRyMrc{mWmSTOv_?qjqa%U1KbiF({sgDk~7Meq^is{ zeZ*%JBKn}<C1K`_yUcT`b*tJ;Xzy>ce)5)>uz;OH<UItEAcXX--#p;cy7>aaqzW65 zsB2}7E_Lei0r?paTJJvRbH8%YxEpzoxW*(o_kv7>9THkoUCNuG*3h6%0mh4-&U8#q z6U*ze!9{<U*Kg5G=a_Ub5V)!endNn1wSwxOV+WRf5GhSzHWyxbn+6ROTx~!*&ZW3< z3ee-vhu%u<SDhs^C+UTAhthCqsb?{q`16w~CQtfpuUtvF_>+gC&!D>fP6yW#w+{B) zj>ys%?(D6oEYjkuiD6V>Owr0HrW;~6)HUhUq#r6xNfJ}lx+6I4M0KlXwpH06>;crK zW3dbu_{8EKA$2gW1h%ok+c1&ydIJ3fceH9wqV)@|ex^Y8{R;|X3###KaL5RckVMXZ zZ2rKi#xsXPgA{A?_;(7|BxWy46xi+XNvTPf!}%e`A3sD0KJYT^_RA$q`Q{{XBM&G4 zQF8$f_EZ$7zM4{7`x0Y>BOO<I8DFUM_KmxUe9Dl$Lh-ME-*(w(^<@uTB;=SGcv$Ta zZLG`#^UyaZfLswO2IMh}UZ@M1DEY|VELEF>uqSgMD{!<{>37hjk*oQL7&`HJhkDW8 zdzs1ZoGBen2f;(tC<gBB{oeP5;TDeQ5FganWQ_VNe~7b{p*9z$k75hP9uA|iEjuNS z<O1P|BQA19$SO5-fiiYNktgP`oHQyLmcu~)xA_YLV=y$p4fEH}oJ93=?pEFWR`oxv zv8>Sdr4&_gB98o*GU|^1Ao!JgQ{o^K)at&cONdOau`+}NR`}<BNM_y3US;{naCt_Q zozktd8tXw@u>py4!V1%E;Ud5QAPfv75U_(Bdrxu46xF~nRidYN^`g;gur>XAp7ZN6 zr71nv&&Kl7MEo~z>Ngq%!xXAY#2>Ut(~nrZa(x8{tMG&@Ny46&Q6My6NhmDKROTi# z3^HWfYqXbncjTwPd6*p}f$|cBnOP1xZ-yJ~|2zFhB~95}MyE7fkPb{a{XanZ2<Hm{ zA3>XRwIezdn~j$F?G7xNZfn=Y@~aJnf$qJOOazzNX)F10_WOyCzvGCWH<fQdU<}!n zyuCm#3S7%+k<7sk(U+mIH-z#^ylAvBTr_^$&MX4fs^XtSsIcdOA<{h1jzNla?=~8N zArSnishxa?HchH|-zXvmhcekBiNHd$E$!UJc4yXfLNQbDI&HFeW6Rppv0>P!u0BRw zLXE)ZG&Oa+a5OO|{=Bz85YB>H)ZDkDOW?FkfFq0+UHU>ol?XoeDfOR2z8U<iHBus~ zG!)^EKOg@K))volgs{vbw{Jqx$~4_*<UHzbWcc#-PP|=?anJ~9c240ZliW0UYLm&m zHaZCA(_=dX)dZ3RaW__(krq-9(!HVeqq(L>w?yz$_CAN2v@UKADvxm5G2ZSY8HLQy z1B%o0PU5H`aN<UI;K@gJyae*QHOa2Nay*PS?p3amQWY_Oz2mVCoU8RJf`2T6Hl`aE z+-Du`JQX~Ff+}zR9b;<vg{@L#4+eIU_64F6H7|XhP?G^cSvaTb)f?wqNuS{Ot_X&+ zY&^!M$ymk6-i}VA>v-A<YTaK+OjrcWd~U%JUrO7-rpQZC8*c<3gwn=c4ynJZ-(79Z z7?hW5?a&_$|HH>@X{4A(O#9NV5b*(58<V@B?Ic7%Lr-pJ#~%c?$CotZXgq<Z4_Sy0 z(sAD~Qb(!4VP(9uygZ+uR7r+3<Rc2Vj9jn4>1leMWRY7ZBjTFx9u`o4mm%R#eT8v= zCoqi^jaKSbT@LxGgSab5e)$;QOxK-jOdU})>lQauBb6gV74hNmPs8UIsXq3VKXahX z6lwoPk5gz_uSg8PP}B|*<2ZWBA2k0-8~&Ca{wtEY_J*LSNhz`A73I)!svHu6m!zSz zrpk%%dMe7sob1f6#UsV<XE%G}GSiJ%-n>|u#a$c^$2djlQ?Jael8};}OKG;sH`9r} zwuxW(OBi4@6~Y|@56;hiLq`5Ftt33)B~ywhS4Jr4`vlOzy~|QVxY~>%*101h0g;Ma z65=ctcDlTaIMfa|LbyIB>hRmb0<tbz<O{frPul(GG@vekU-4k8w-Ju%L#QfiDU~Z) G2K^7oM9ee* literal 0 HcmV?d00001 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.'); + }); + }); +});