1/** 2 * InmoPortal â shared public JS (no jQuery, ES2017). 3 * 4 * Exposes window.Inmo = { qs, qsa, esc, debounce, formatPrice, ajax, notify, favs } 5 * for the other module bundles, and wires up: 6 * - favorites (localStorage) on every .inmo-fav button (event delegation) 7 * - the search page ([data-inmo-search]): AJAX filters, pagination, pushState, list/map toggle 8 * - the map page ([data-inmo-map-page]): inline or AJAX-loaded points 9 */ 10( function () { 11 'use strict'; 12 13 if ( typeof window.InmoCfg === 'undefined' ) { 14 return; 15 } 16 var Cfg = window.InmoCfg; 17 18 /* ------------------------------------------------------------------ 19 * Utilities 20 * ---------------------------------------------------------------- */ 21 22 function qs( sel, ctx ) { 23 return ( ctx || document ).querySelector( sel ); 24 } 25 26 function qsa( sel, ctx ) { 27 return Array.prototype.slice.call( ( ctx || document ).querySelectorAll( sel ) ); 28 } 29 30 function esc( s ) { 31 return String( s == null ? '' : s ).replace( /[&<>"']/g, function ( c ) { 32 return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ c ]; 33 } ); 34 } 35 36 function debounce( fn, wait ) { 37 var t = null; 38 return function () { 39 var args = arguments, self = this; 40 clearTimeout( t ); 41 t = setTimeout( function () { 42 fn.apply( self, args ); 43 }, wait || 300 ); 44 }; 45 } 46 47 function formatPrice( n, op ) { 48 n = parseFloat( n ) || 0; 49 var out = n.toLocaleString( 'es-ES' ) + ' ' + ( Cfg.currency || 'â¬' ); 50 if ( op === 'rent' ) { 51 out += '/mes'; 52 } 53 return out; 54 } 55 56 /** 57 * POST to admin-ajax. `data` may be a plain object, FormData or URLSearchParams. 58 * Resolves with the `data` half of a wp_send_json_success response; rejects with 59 * an Error whose .data carries the error payload otherwise. 60 */ 61 function ajax( action, data, opts ) { 62 opts = opts || {}; 63 var body; 64 if ( data instanceof FormData || data instanceof URLSearchParams ) { 65 body = data; 66 } else { 67 body = new URLSearchParams(); 68 Object.keys( data || {} ).forEach( function ( k ) { 69 var v = data[ k ]; 70 if ( v === null || typeof v === 'undefined' ) { 71 return; 72 } 73 if ( Array.isArray( v ) ) { 74 v.forEach( function ( item ) { 75 body.append( k + '[]', item ); 76 } ); 77 } else { 78 body.append( k, v ); 79 } 80 } ); 81 } 82 body.append( 'action', action ); 83 if ( ! body.has || ! body.has( 'nonce' ) ) { 84 body.append( 'nonce', opts.user ? ( Cfg.user_nonce || Cfg.nonce ) : Cfg.nonce ); 85 } 86 return fetch( Cfg.ajax_url, { method: 'POST', credentials: 'same-origin', body: body } ) 87 .then( function ( r ) { 88 return r.json(); 89 } ) 90 .then( function ( res ) { 91 if ( res && res.success ) { 92 return res.data; 93 } 94 var msg = ( res && res.data && res.data.message ) ? res.data.message : Cfg.i18n.error; 95 var err = new Error( msg ); 96 err.data = res && res.data; 97 throw err; 98 } ); 99 } 100 101 /* Toast notifications ------------------------------------------------ */ 102 103 function notify( msg, type ) { 104 var wrap = qs( '.inmo-toasts' ); 105 if ( ! wrap ) { 106 wrap = document.createElement( 'div' ); 107 wrap.className = 'inmo-toasts'; 108 wrap.setAttribute( 'aria-live', 'polite' ); 109 document.body.appendChild( wrap ); 110 } 111 var t = document.createElement( 'div' ); 112 t.className = 'inmo-toast inmo-toast--' + ( type || 'info' ); 113 t.textContent = msg; 114 wrap.appendChild( t ); 115 requestAnimationFrame( function () { 116 t.classList.add( 'is-in' ); 117 } ); 118 setTimeout( function () { 119 t.classList.remove( 'is-in' ); 120 setTimeout( function () { 121 if ( t.parentNode ) { 122 t.parentNode.removeChild( t ); 123 } 124 }, 300 ); 125 }, 3500 ); 126 } 127 128 /* Favorites ---------------------------------------------------------- */ 129 130 var FAV_KEY = 'inmo_favs'; 131 132 function favList() { 133 try { 134 var v = JSON.parse( window.localStorage.getItem( FAV_KEY ) ); 135 return Array.isArray( v ) ? v : []; 136 } catch ( e ) { 137 return []; 138 } 139 } 140 141 function favSave( list ) { 142 try { 143 window.localStorage.setItem( FAV_KEY, JSON.stringify( list ) ); 144 } catch ( e ) { /* private mode */ } 145 } 146 147 function favHas( id ) { 148 return favList().indexOf( parseInt( id, 10 ) ) !== -1; 149 } 150 151 function favToggle( id ) { 152 id = parseInt( id, 10 ); 153 var list = favList(); 154 var i = list.indexOf( id ); 155 if ( i === -1 ) { 156 list.push( id ); 157 } else { 158 list.splice( i, 1 ); 159 } 160 favSave( list ); 161 return i === -1; 162 } 163 164 function favPaint( btn, on ) { 165 btn.classList.toggle( 'is-fav', on ); 166 btn.setAttribute( 'aria-pressed', on ? 'true' : 'false' ); 167 } 168 169 function markFavs( root ) { 170 qsa( '.inmo-fav', root ).forEach( function ( btn ) { 171 favPaint( btn, favHas( btn.getAttribute( 'data-id' ) ) ); 172 } ); 173 } 174 175 document.addEventListener( 'click', function ( e ) { 176 var btn = e.target.closest ? e.target.closest( '.inmo-fav' ) : null; 177 if ( ! btn ) { 178 return; 179 } 180 e.preventDefault(); 181 e.stopPropagation(); 182 var on = favToggle( btn.getAttribute( 'data-id' ) ); 183 favPaint( btn, on ); 184 notify( on ? Cfg.i18n.favorite : Cfg.i18n.unfavorite, 'info' ); 185 } ); 186 187 /* Public API --------------------------------------------------------- */ 188 189 window.Inmo = { 190 qs: qs, 191 qsa: qsa, 192 esc: esc, 193 debounce: debounce, 194 formatPrice: formatPrice, 195 ajax: ajax, 196 notify: notify, 197 favs: { list: favList, has: favHas, toggle: favToggle, mark: markFavs } 198 }; 199 200 /* ------------------------------------------------------------------ 201 * Search page controller 202 * ---------------------------------------------------------------- */ 203 204 function initSearch( root ) { 205 var form = qs( '[data-inmo-filters]', root ); 206 var grid = qs( '[data-grid]', root ); 207 var pag = qs( '[data-pagination]', root ); 208 var countEl = qs( '[data-count]', root ); 209 var mapPanel = qs( '[data-map-panel]', root ); 210 var mapEl = mapPanel ? qs( '[data-map]', mapPanel ) : null; 211 var results = qs( '[data-results]', root ); 212 var viewBtns = qsa( '[data-view]', root ); 213 214 if ( ! grid ) { 215 return; 216 } 217 218 var state = {
219 page: parseInt( root.getAttribute( 'data-page' ) || '1', 10 ), 220 perPage: root.getAttribute( 'data-per-page' ) || '', 221 owner: root.getAttribute( 'data-owner' ) || '', 222 points: null, // null = not fetched yet 223 map: null, 224 mapLayer: null, 225 mapVisible: false 226 }; 227 228 if ( form ) { 229 form.classList.add( 'is-js' ); 230 } 231 markFavs( grid ); 232 233 function collect( page ) { 234 var params = new URLSearchParams(); 235 // Pinned shortcode atts (data-op/type/zone) apply when the form has no such field. 236 [ 'op', 'type', 'zone' ].forEach( function ( k ) { 237 var v = root.getAttribute( 'data-' + k ); 238 if ( v && ! ( form && form.elements[ k ] ) ) { 239 params.set( k, v ); 240 } 241 } ); 242 if ( form ) { 243 new FormData( form ).forEach( function ( v, k ) { 244 if ( v !== '' && v !== null ) { 245 params.set( k, v ); 246 } 247 } ); 248 } 249 if ( page && page > 1 ) { 250 params.set( 'pg', page ); 251 } 252 return params; 253 } 254 255 function activeFilterCount( params ) { 256 var n = 0; 257 [ 'type', 'zone', 'pmin', 'pmax', 'rooms', 'm2', 'q' ].forEach( function ( k ) { 258 if ( params.get( k ) ) { 259 n++; 260 } 261 } ); 262 return n; 263 } 264 265 function updateFilterBadge( params ) { 266 var badge = form ? qs( '[data-filters-count]', form ) : null; 267 if ( ! badge ) { 268 return; 269 } 270 var n = activeFilterCount( params ); 271 badge.textContent = n; 272 badge.classList.toggle( 'inmo-hidden', n === 0 ); 273 } 274 275 function pushUrl( params ) { 276 var p = new URLSearchParams( params.toString() ); 277 p.delete( 'per_page' ); 278 p.delete( 'owner' ); 279 var url = window.location.pathname + ( p.toString() ? '?' + p.toString() : '' ); 280 window.history.pushState( { inmo: 1 }, '', url ); 281 } 282 283 function refresh( page, push ) { 284 var params = collect( page ); 285 updateFilterBadge( params ); 286 grid.classList.add( 'is-loading' ); 287 grid.setAttribute( 'aria-busy', 'true' ); 288 289 var body = new URLSearchParams( params.toString() ); 290 if ( state.perPage ) { 291 body.set( 'per_page', state.perPage ); 292 } 293 if ( state.owner ) { 294 body.set( 'owner', state.owner ); 295 } 296 297 ajax( 'inmo_search', body ) 298 .then( function ( data ) { 299 grid.classList.remove( 'is-loading' ); 300 grid.removeAttribute( 'aria-busy' ); 301 grid.innerHTML = data.html; 302 if ( pag ) { 303 pag.innerHTML = data.pagination || ''; 304 } 305 if ( countEl ) { 306 countEl.textContent = data.count_label || ''; 307 } 308 state.page = data.page; 309 state.points = data.points || []; 310 markFavs( grid ); 311 if ( state.mapVisible ) { 312 renderPoints(); 313 } 314 if ( push !== false ) { 315 pushUrl( params ); 316 } 317 } ) 318 .catch( function ( err ) { 319 grid.classList.remove( 'is-loading' ); 320 grid.removeAttribute( 'aria-busy' ); 321 notify( err.message || Cfg.i18n.error, 'error' ); 322 } ); 323 } 324 325 /* Filters form */ 326 if ( form ) { 327 // Se busca cuando el usuario lo pide: con el botón «Buscar» o con Enter en un campo. 328 // Antes bastaba tocar un desplegable para relanzar la búsqueda, y por eso el botón estaba 329 // escondido; el resultado cambiaba bajo los pies mientras aún se estaban eligiendo filtros. 330 form.addEventListener( 'submit', function ( e ) { 331 e.preventDefault(); 332 refresh( 1 ); 333 } ); 334 form.addEventListener( 'change', function ( e ) { 335 // Venta/Alquiler es la ÃNICA que busca sola. No es un filtro más: funciona como 336 // una pestaña, y nadie elige una pestaña esperando tener que confirmarla después. 337 // Los demás campos siguen esperando al botón, por lo de siempre: cambiar de barrio 338 // o de precio mientras aún estás eligiendo movÃa la lista bajo los pies. 339 if ( e.target.name === 'op' ) { 340 // El orden importa: swapPriceSteps() cambia los tramos âno es lo mismo 341 // 300.000 ⬠que 900 â¬/mesâ y borra el precio elegido si no existe en los 342 // nuevos, asà que hay que buscar DESPUÃS o se enviarÃa un precio de la otra 343 // operación. 344 swapPriceSteps(); 345 paintSegs(); 346 refresh( 1 ); 347 } 348 } ); 349 350 var clearBtn = qs( '[data-clear]', form ); 351 if ( clearBtn ) { 352 clearBtn.addEventListener( 'click', function ( e ) { 353 e.preventDefault(); 354 qsa( 'input, select', form ).forEach( function ( el ) { 355 if ( el.type === 'radio' ) { 356 el.checked = el.value === ''; 357 } else if ( el.type !== 'submit' && el.type !== 'button' ) { 358 el.value = ''; 359 } 360 } ); 361 swapPriceSteps(); 362 paintSegs(); 363 refresh( 1 ); 364 } ); 365 } 366 367 var toggle = qs( '[data-filters-toggle]', form ); 368 var bodyEl = qs( '[data-filters-body]', form ); 369 if ( toggle && bodyEl ) { 370 toggle.addEventListener( 'click', function () { 371 var open = form.classList.toggle( 'is-open' ); 372 toggle.setAttribute( 'aria-expanded', open ? 'true' : 'false' ); 373 } ); 374 } 375 } 376 377 /* .inmo-seg labels: keep the .is-on class in sync (CSS :has fallback) */ 378 function paintSegs() { 379 qsa( '.inmo-seg', root ).forEach( function ( seg ) { 380 qsa( 'label', seg ).forEach( function ( lb ) { 381 var input = qs( 'input', lb ); 382 if ( input ) { 383 lb.classList.toggle( 'is-on', input.checked ); 384 } 385 } ); 386 } ); 387 } 388 389 /* Price selects follow the operation */ 390 function swapPriceSteps() { 391 if ( ! form ) { 392 return; 393 } 394 var op = ( new FormData( form ) ).get( 'op' ) || ''; 395 var attr = op === 'rent' ? 'data-steps-rent' : 'data-steps-sale'; 396 var steps; 397 try { 398 steps = JSON.parse( form.getAttribute( attr ) ) || []; 399 } catch ( e ) { 400 return; 401 } 402 qsa( '[data-price-select]', form ).forEach( function ( sel ) { 403 var current = sel.value; 404 var first = sel.options.length ? sel.options[ 0 ].cloneNode( true ) : null; 405 sel.innerHTML = ''; 406 if ( first ) { 407 sel.appendChild( first ); 408 } 409 steps.forEach( function ( v ) { 410 var o = document.createElement( 'option' ); 411 o.value = v;
412 o.textContent = v.toLocaleString( 'es-ES' ) + ' â¬' + ( op === 'rent' ? '/mes' : '' ); 413 sel.appendChild( o ); 414 } ); 415 sel.value = current; 416 if ( sel.value !== current ) { 417 sel.value = ''; 418 } 419 } ); 420 } 421 422 /* Pagination (delegated: the links are re-rendered on every refresh) */ 423 root.addEventListener( 'click', function ( e ) { 424 var a = e.target.closest ? e.target.closest( 'a[data-page]' ) : null; 425 if ( ! a || ! root.contains( a ) ) { 426 return; 427 } 428 e.preventDefault(); 429 refresh( parseInt( a.getAttribute( 'data-page' ), 10 ) || 1 ); 430 var top = root.getBoundingClientRect().top + window.pageYOffset - 70; 431 window.scrollTo( { top: top > 0 ? top : 0, behavior: 'smooth' } ); 432 } ); 433 434 /* List / map toggle */ 435 function setView( view ) { 436 viewBtns.forEach( function ( b ) { 437 var on = b.getAttribute( 'data-view' ) === view; 438 b.classList.toggle( 'is-on', on ); 439 b.setAttribute( 'aria-pressed', on ? 'true' : 'false' ); 440 } ); 441 state.mapVisible = view === 'map'; 442 if ( results ) { 443 results.classList.toggle( 'inmo-hidden', state.mapVisible ); 444 } 445 if ( mapPanel ) { 446 mapPanel.classList.toggle( 'inmo-hidden', ! state.mapVisible ); 447 } 448 if ( state.mapVisible ) { 449 ensureMap(); 450 } 451 } 452 viewBtns.forEach( function ( b ) { 453 b.addEventListener( 'click', function () { 454 setView( b.getAttribute( 'data-view' ) ); 455 } ); 456 } ); 457 458 function ensureMap() { 459 if ( ! mapEl ) { 460 return; 461 } 462 if ( ! window.L || ! window.InmoMap ) { 463 notify( Cfg.i18n.map_error, 'error' ); 464 return; 465 } 466 if ( ! state.map ) { 467 state.map = window.InmoMap.create( mapEl, { scrollWheelZoom: true } ); 468 } 469 setTimeout( function () { 470 state.map.invalidateSize(); 471 if ( state.points === null ) { 472 // Initial page load carries no points: fetch them for the current filters. 473 var body = collect( 1 ); 474 ajax( 'inmo_search', body ) 475 .then( function ( data ) { 476 state.points = data.points || []; 477 renderPoints(); 478 } ) 479 .catch( function ( err ) { 480 notify( err.message || Cfg.i18n.error, 'error' ); 481 } ); 482 } else { 483 renderPoints(); 484 } 485 }, 80 ); 486 } 487 488 function renderPoints() { 489 if ( ! state.map || ! window.InmoMap ) { 490 return; 491 } 492 if ( state.mapLayer ) { 493 state.map.removeLayer( state.mapLayer ); 494 state.mapLayer = null; 495 } 496 var pts = state.points || []; 497 if ( ! pts.length ) { 498 notify( Cfg.i18n.map_empty, 'info' ); 499 return; 500 } 501 state.mapLayer = window.InmoMap.addPoints( state.map, pts, { popup: true, prices: true } ); 502 } 503 504 /* Back/forward: re-apply the URL filters */ 505 window.addEventListener( 'popstate', function () { 506 if ( ! form ) { 507 return; 508 } 509 var p = new URLSearchParams( window.location.search ); 510 qsa( 'input, select', form ).forEach( function ( el ) { 511 if ( ! el.name ) { 512 return; 513 } 514 var v = p.get( el.name ) || ''; 515 if ( el.type === 'radio' ) { 516 el.checked = el.value === v; 517 } else { 518 el.value = v; 519 } 520 } ); 521 swapPriceSteps(); 522 paintSegs(); 523 refresh( parseInt( p.get( 'pg' ) || '1', 10 ), false ); 524 } ); 525 526 paintSegs(); 527 } 528 529 /* ------------------------------------------------------------------ 530 * Map page ([inmo_map]) 531 * ---------------------------------------------------------------- */ 532 533 function initMapPage( root ) { 534 var mapEl = qs( '[data-map]', root ); 535 var form = qs( '[data-map-filters]', root ); 536 var countEl = qs( '[data-count]', root ); 537 if ( ! mapEl || ! window.L || ! window.InmoMap ) { 538 return; 539 } 540 541 var map = window.InmoMap.create( mapEl, { 542 scrollWheelZoom: false, 543 zoom: parseInt( mapEl.getAttribute( 'data-zoom' ) || '0', 10 ) || undefined 544 } ); 545 var layer = null; 546 547 function show( points ) { 548 if ( layer ) { 549 map.removeLayer( layer ); 550 layer = null; 551 } 552 if ( countEl ) { 553 var tpl = points.length === 1 ? Cfg.i18n.result : Cfg.i18n.results; 554 countEl.textContent = tpl.replace( '%s', points.length.toLocaleString( 'es-ES' ) ); 555 } 556 if ( ! points.length ) { 557 notify( Cfg.i18n.map_empty, 'info' ); 558 return; 559 } 560 layer = window.InmoMap.addPoints( map, points, { popup: true, prices: true } ); 561 } 562 563 function load() { 564 var body = new URLSearchParams(); 565 if ( form ) { 566 new FormData( form ).forEach( function ( v, k ) { 567 if ( v !== '' ) { 568 body.set( k, v ); 569 } 570 } ); 571 } 572 if ( countEl ) { 573 countEl.textContent = Cfg.i18n.loading; 574 } 575 ajax( 'inmo_map_points', body ) 576 .then( function ( data ) { 577 show( data.points || [] ); 578 if ( countEl && data.count_label ) { 579 countEl.textContent = data.count_label; 580 } 581 } ) 582 .catch( function ( err ) { 583 notify( err.message || Cfg.i18n.error, 'error' ); 584 } ); 585 } 586 587 // Inline points when the set is small; otherwise fetch.
588 var inline = mapEl.getAttribute( 'data-points' ); 589 if ( inline ) { 590 try { 591 show( JSON.parse( inline ) || [] ); 592 } catch ( e ) { 593 load(); 594 } 595 } else { 596 load(); 597 } 598 599 if ( form ) { 600 form.classList.add( 'is-js' ); 601 form.addEventListener( 'submit', function ( e ) { 602 e.preventDefault(); 603 load(); 604 } ); 605 form.addEventListener( 'change', function () { 606 qsa( '.inmo-seg label', form ).forEach( function ( lb ) { 607 var input = qs( 'input', lb ); 608 if ( input ) { 609 lb.classList.toggle( 'is-on', input.checked ); 610 } 611 } ); 612 load(); 613 } ); 614 } 615 } 616 617 /* ------------------------------------------------------------------ 618 * Carousel ([inmo_latest]/[inmo_featured] con carousel="1") 619 * ---------------------------------------------------------------- */ 620 621 function initCarousel( root ) { 622 var track = qs( '.inmo-carousel__track', root ); 623 var prev = qs( '[data-carousel-prev]', root ); 624 var next = qs( '[data-carousel-next]', root ); 625 if ( ! track || ! prev || ! next ) { 626 return; 627 } 628 629 function update() { 630 // Umbral de 2px: el scroll fraccionario del zoom del navegador deja restos de 0,5px 631 // y con una comparación exacta la flecha del final no se apagaba nunca. 632 var max = track.scrollWidth - track.clientWidth; 633 root.classList.toggle( 'is-ready', max > 2 ); 634 prev.disabled = track.scrollLeft <= 2; 635 next.disabled = track.scrollLeft >= max - 2; 636 } 637 638 function page( dir ) { 639 track.scrollBy( { left: dir * Math.max( track.clientWidth * 0.9, 200 ), behavior: 'smooth' } ); 640 } 641 642 prev.addEventListener( 'click', function () { 643 page( -1 ); 644 } ); 645 next.addEventListener( 'click', function () { 646 page( 1 ); 647 } ); 648 track.addEventListener( 'scroll', debounce( update, 80 ) ); 649 window.addEventListener( 'resize', debounce( update, 150 ) ); 650 // Las fotos van en lazy: hasta que no cargan, scrollWidth miente y las flechas 651 // se calcularÃan sobre un ancho que todavÃa no es el definitivo. 652 window.addEventListener( 'load', update ); 653 update(); 654 } 655 656 /* ------------------------------------------------------------------ 657 * Boot 658 * ---------------------------------------------------------------- */ 659 660 function boot() { 661 markFavs( document ); 662 qsa( '[data-inmo-search]' ).forEach( initSearch ); 663 qsa( '[data-inmo-map-page]' ).forEach( initMapPage ); 664 qsa( '[data-inmo-carousel]' ).forEach( initCarousel ); 665 } 666 667 if ( document.readyState === 'loading' ) { 668 document.addEventListener( 'DOMContentLoaded', boot ); 669 } else { 670 boot(); 671 } 672} )();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.