diff options
Diffstat (limited to 'static')
-rw-r--r-- | static/app.js | 235 | ||||
-rw-r--r-- | static/autocomplete.js | 127 | ||||
-rw-r--r-- | static/style.css | 0 |
3 files changed, 362 insertions, 0 deletions
diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..0f104fb --- /dev/null +++ b/static/app.js @@ -0,0 +1,235 @@ +class Search { + constructor() { + this.searchDebounced = _.debounce((query) => { + if (query.length < 3) + return; + + fetch(`/hints.ajax?q=${encodeURIComponent(query)}`) + .then(response => { + if (!response.ok) + throw new Error(`statusText is ${response.statusText}`); + + return response.json(); + }) + .then(({response, error}) => { + this.unlockButton(); + + if (error) + throw new Error(error); + + this.autoComplete.setData(response.map(item => { + return {label: item, value: ''}; + })); + this.autoComplete.renderIfNeeded(); + }) + }, 150); + + this._filterDebounced = _.debounce((e) => { + let filter = e.target; + globalMaps.setFilter(filter.value); + }, 500) + + let field = document.getElementById('queryInput'); + let btn = document.getElementById('querySubmit'); + let filterField = document.getElementById('filterInput'); + + this.autoComplete = new Autocomplete(field, { + data: [], + maximumItems: 10, + onInput: (value) => { + this.searchDebounced(value); + }, + onSelectItem: ({label}) => { + // console.log('selected:', label) + }, + highlightClass: 'text-danger' + }); + + btn.addEventListener('click', this.onSubmit); + field.addEventListener('keydown', this.onInputKeyDown); + filterField.addEventListener('input', this._filterDebounced); + + this.btn = btn; + this.field = field; + } + + onInputKeyDown = (e) => { + if (e.keyCode === 10 || e.keyCode === 13) + this.onSubmit(); + } + + getOffers(query, page) { + fetch(`/offers.ajax?q=${encodeURIComponent(this.field.value)}&page=${page}`) + .then(response => { + if (!response.ok) + throw new Error(`statusText is ${response.statusText}`); + + return response.json(); + }) + .then(({error, tradeNames, end, offers, pages}) => { + if (error) + throw new Error(error); + + if (tradeNames) { + this.autoComplete.setData(tradeNames.map(item => { + return {label: item, value: ''}; + })); + this.autoComplete.renderIfNeeded(); + return this.unlockButton(); + } + + for (let offer of offers) + globalMaps.addOffer(offer); + + if (page >= pages) { + return this.unlockButton(); + } else { + this.lockButton(pages > 1 ? `${page} из ${pages}` : null); + setTimeout(() => { + this.getOffers(query, page + 1); + }, 1000) + } + }) + .catch((error) => { + console.error(error); + alert(error); + this.unlockButton(); + }) + } + + onSubmit = (e) => { + if (this.isLocked()) + return; + + this.lockButton('Загрузка...'); + + globalMaps.removeAllPoints(); + + this.getOffers(this.field.value, 1); + } + + isLocked() { + return this.btn.classList.contains('disabled'); + } + + lockButton(text) { + if (text !== null) + this.btn.innerText = text; + this.btn.classList.add('disabled'); + } + + unlockButton() { + this.btn.classList.remove('disabled'); + this.btn.innerText = 'Поиск'; + } +} + + +class Maps { + constructor() { + /** + * @type {ymaps.Map} + */ + this.map = null; + ymaps.ready(this.onInit); + + this.filter = null; + this.places = {}; + } + + onInit = () => { + this.map = new ymaps.Map("mapContainer", { + center: [59.94, 30.32], + zoom: 11 + }); + this.map.controls.remove('searchControl'); + } + + addPoint({geo, offersRef, hint, pharmacyName, pharmacyAddress, pharmacyPhone}) { + let mark = new ymaps.Placemark(geo, { + hintContent: hint, + }, { + preset: 'islands#dotIcon', + openEmptyBalloon: true, + iconColor: '#3caa3c' + }); + mark.events.add('balloonopen', e => { + let lines = offersRef.map(offer => { + return `${offer.name} (${offer.price} руб.)` + }); + let html = `<b>${pharmacyName}</b><br>`; + html += `${pharmacyAddress}<br>`; + html += `тел: ${pharmacyPhone}<br><br>`; + html += lines.join('<br>'); + mark.properties.set('balloonContent', html); + }); + this.map.geoObjects.add(mark); + return mark; + } + + removeAllPoints() { + this.map.geoObjects.removeAll(); + } + + addOffer(offer) { + // console.log('[addOffer]', offer); + let hash = offer.pharmacy.hash; + if (hash in this.places) + this.places[hash].offers.push(offer); + else + this.places[hash] = {offers: [offer]}; + + if (!this.places[hash].mark && this.isAllowed(offer.name)) + this.showPlaceOnMap(hash, offer); + } + + showPlaceOnMap(hash, offer) { + this.places[hash].mark = this.addPoint({ + geo: offer.pharmacy.geo, + hint: offer.pharmacy.name, + pharmacyName: offer.pharmacy.name, + pharmacyAddress: offer.pharmacy.address, + pharmacyPhone: offer.pharmacy.phone, + offersRef: this.places[hash].offers + }); + } + + hidePlaceFromMap(hash) { + if (this.places[hash].mark) { + this.map.geoObjects.remove(this.places[hash].mark); + delete this.places[hash].mark; + } + } + + setFilter(filter) { + if (!filter) + filter = null; + this.filter = filter; + + for (let hash in this.places) { + if (!this.places.hasOwnProperty(hash)) + continue; + + let pl = this.places[hash]; + let ok = pl.offers.filter(o => this.isAllowed(o.name)) + + if (pl.mark && !ok.length) + this.hidePlaceFromMap(hash); + + else if (!pl.mark && ok.length) + this.showPlaceOnMap(hash, ok[0]); + } + } + + isAllowed(productName) { + return this.filter === null || productName.indexOf(this.filter) !== -1; + } +} + +let globalMaps = null; +let globalSearch = null; + +window.addEventListener('DOMContentLoaded', function() { + globalSearch = new Search(); + globalMaps = new Maps(); +});
\ No newline at end of file diff --git a/static/autocomplete.js b/static/autocomplete.js new file mode 100644 index 0000000..90629a2 --- /dev/null +++ b/static/autocomplete.js @@ -0,0 +1,127 @@ +const DEFAULTS = { + treshold: 2, + maximumItems: 5, + highlightTyped: true, + highlightClass: 'text-primary', +}; + +class Autocomplete { + constructor(field, options) { + this.field = field; + this.options = Object.assign({}, DEFAULTS, options); + this.dropdown = null; + + field.parentNode.classList.add('dropdown'); + field.setAttribute('data-toggle', 'dropdown'); + field.classList.add('dropdown-toggle'); + + const dropdown = ce(`<div class="dropdown-menu" ></div>`); + if (this.options.dropdownClass) + dropdown.classList.add(this.options.dropdownClass); + + insertAfter(dropdown, field); + + this.dropdown = new bootstrap.Dropdown(field, this.options.dropdownOptions) + + field.addEventListener('click', (e) => { + if (this.createItems() === 0) { + e.stopPropagation(); + this.dropdown.hide(); + } + }); + + field.addEventListener('input', () => { + if (this.options.onInput) + this.options.onInput(this.field.value); + this.renderIfNeeded(); + }); + + field.addEventListener('keydown', (e) => { + if (e.keyCode === 27) { + this.dropdown.hide(); + return; + } + }); + } + + setData(data) { + this.options.data = data; + } + + renderIfNeeded() { + if (this.createItems() > 0) + this.dropdown.show(); + else + this.field.click(); + } + + createItem(lookup, item) { + let label; + if (this.options.highlightTyped) { + const idx = item.label.toLowerCase().indexOf(lookup.toLowerCase()); + const className = Array.isArray(this.options.highlightClass) ? this.options.highlightClass.join(' ') + : (typeof this.options.highlightClass == 'string' ? this.options.highlightClass : '') + label = item.label.substring(0, idx) + + `<span class="${className}">${item.label.substring(idx, idx + lookup.length)}</span>` + + item.label.substring(idx + lookup.length, item.label.length); + } else + label = item.label; + return ce(`<button type="button" class="dropdown-item" data-value="${item.value}">${label}</button>`); + } + + createItems() { + const lookup = this.field.value; + if (lookup.length < this.options.treshold) { + this.dropdown.hide(); + return 0; + } + + const items = this.field.nextSibling; + items.innerHTML = ''; + + let count = 0; + for (let i = 0; i < this.options.data.length; i++) { + const {label, value} = this.options.data[i]; + const item = {label, value}; + if (item.label.toLowerCase().indexOf(lookup.toLowerCase()) >= 0) { + items.appendChild(this.createItem(lookup, item)); + if (this.options.maximumItems > 0 && ++count >= this.options.maximumItems) + break; + } + } + + this.field.nextSibling.querySelectorAll('.dropdown-item').forEach((item) => { + item.addEventListener('click', (e) => { + let dataValue = e.target.getAttribute('data-value'); + this.field.value = e.target.innerText; + if (this.options.onSelectItem) + this.options.onSelectItem({ + value: e.target.dataset.value, + label: e.target.innerText, + }); + this.dropdown.hide(); + }) + }); + + return items.childNodes.length; + } +} + +/** + * @param html + * @returns {Node} + */ +function ce(html) { + let div = document.createElement('div'); + div.innerHTML = html; + return div.firstChild; +} + +/** + * @param elem + * @param refElem + * @returns {*} + */ +function insertAfter(elem, refElem) { + return refElem.parentNode.insertBefore(elem, refElem.nextSibling) +}
\ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/static/style.css |