PageSourceSearch

https://manilva.ws/wp-content/plugins/inmo-portal/assets/js/public.js?ver=1.38.0

js manilva.ws collected 2026-09-24 10:17:02 UTC 19,156 bytes, 672 lines download raw bytes

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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ 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.