PageSourceSearch

https://welcometoibiza.com/wp-content/plugins/wti-mesas-vip/assets/js/form-1.6.1.js

js welcometoibiza.com collected 2026-09-24 23:40:57 UTC 24,012 bytes, 706 lines download raw bytes

1/**
2 * Archivo: assets/js/form.js
3 * Ubicación: /wp-content/plugins/wti-mesas-vip/assets/js/
4 * Creado: 2026-08-02 12:40
5 *
6 * Intercepta el botón MESAS VIP, abre el formulario y, al enviar, abre
7 * WhatsApp con la solicitud ya escrita. No se guarda nada en el servidor.
8 *
9 * No modifica wti-core: la detección se hace sobre el enlace ya renderizado.
10 *
11 * Idioma: español para quien navega en español, inglés para el resto.
12 * Se decide AQUÍ, en el navegador, no en el servidor: la web va cacheada
13 * con WP Rocket y el servidor entrega la misma página a todo el mundo.
14 *
15 * Este archivo se carga en todas las páginas, esté el formulario activo o
16 * no, y no hace NADA hasta que alguien toca el botón MESAS VIP. Entonces
17 * pregunta al servidor si tiene que abrirse. Va así porque la web está
18 * cacheada: si la decisión viniera dentro de la página, las copias viejas
19 * seguirían con el comportamiento antiguo (incidencia 2026-08-02, con la
20 * mitad de la web —y todo el móvil— sirviendo la versión sin formulario).
21 */
22(function () {
23	'use strict';
24
25	if (typeof WTI_MVIP_BOOT === 'undefined' || !WTI_MVIP_BOOT.configUrl) {
26		return;
27	}
28
29	var BOOT = WTI_MVIP_BOOT;
30
31	// Se rellenan cuando llega la configuración del servidor.
32	var CFG = null;
33
34	// ───────────────────────────────────────────────────────────
35	//  0. Idioma
36	// ───────────────────────────────────────────────────────────
37
38	/** Prefijos de idioma que la web usa en la URL (GTranslate / Polylang). */
39	var URL_LANGS = ['es', 'en', 'fr', 'de', 'it', 'nl', 'pt', 'ca', 'ru',
40		'pl', 'sv', 'da', 'no', 'fi', 'ja', 'zh', 'ar'];
41
42	/**
43	 * Español solo si el visitante navega en español. Cualquier otro idioma
44	 * ve el inglés.
45	 *
46	 * Manda el idioma elegido en la URL (/en/, /fr/…) porque es una decisión
47	 * expresa del visitante. Si no hay prefijo, manda el navegador — no el
48	 * "lang" del HTML, que en la web base siempre dice es-ES y dejaría a los
49	 * extranjeros con el formulario en español.
50	 */
51	function detectLang() {
52		var m = /^\/([a-z]{2})(?:[-_][a-z]{2})?(?:\/|$)/i.exec(window.location.pathname);
53		if (m && URL_LANGS.indexOf(m[1].toLowerCase()) !== -1) {
54			return 'es' === m[1].toLowerCase() ? 'es' : 'en';
55		}
56
57		var nav = (navigator.language || navigator.userLanguage || '').toLowerCase();
58		if (!nav && navigator.languages && navigator.languages.length) {
59			nav = String(navigator.languages[0]).toLowerCase();
60		}
61		if (nav) {
62			return 0 === nav.indexOf('es') ? 'es' : 'en';
63		}
64
65		// Sin dato ninguno: nos quedamos con el idioma de la página.
66		var html = (document.documentElement.getAttribute('lang') || '').toLowerCase();
67		return 0 === html.indexOf('es') ? 'es' : 'en';
68	}
69
70	var LANG = detectLang();
71	var T = null;
72	var AGES = [];
73	var GROUPS = [];
74	var MESES = [];
75
76	// ───────────────────────────────────────────────────────────
77	//  0.bis Configuración: se pide al servidor, no viene en la página
78	// ───────────────────────────────────────────────────────────
79
80	var configPromise = null;
81
82	function loadConfig() {
83		if (configPromise) { return configPromise; }
84
85		configPromise = new Promise(function (resolve) {
86			var done = false;
87			function finish(cfg) {
88				if (done) { return; }
89				done = true;
90				resolve(cfg);
91			}
92
93			// Si el servidor tarda o falla, el botón se comporta como siempre.
94			var timer = setTimeout(function () { finish(null); }, 4000);
95
96			// El servidor le pone caducidad de un año a TODAS las respuestas,
97			// también a esta. Con una dirección distinta cada vez, ninguna
98			// caché puede devolver una configuración vieja.
99			var url = BOOT.configUrl
100				+ (BOOT.configUrl.indexOf('?') === -1 ? '?' : '&')
101				+ '_=' + Date.now();
102
103			var req = new XMLHttpRequest();
104			req.open('GET', url, true);
105			req.setRequestHeader('Accept', 'application/json');
106			req.setRequestHeader('Cache-Control', 'no-cache');
107			req.onload = function () {
108				clearTimeout(timer);
109				try {
110					finish(JSON.parse(req.responseText));
111				} catch (e) {
112					finish(null);
113				}
114			};
115			req.onerror = function () { clearTimeout(timer); finish(null); };
116			req.send();
117		}).then(function (cfg) {
118			if (cfg && applyConfig(cfg)) { return cfg; }
119			return null;
120		});
121
122		return configPromise;
123	}
124
125	/** ¿Está el formulario activo para quien está viendo esto ahora mismo? */
126	function applyConfig(cfg) {
127		var on = ('form_all' === cfg.mode)
128			|| ('form_admin' === cfg.mode && BOOT.isAdmin);
129		if (!on || !cfg.phone || !cfg.texts) { return false; }
130
131		CFG = cfg;
132		T = cfg.texts[LANG] || cfg.texts.es;
133		AGES = (cfg.ageRanges && cfg.ageRanges[LANG]) || [];
134		GROUPS = (cfg.groupTypes && cfg.groupTypes[LANG]) || [];
135		MESES = (T && T.months) || [];
136		return true;
137	}
138
139	/**
140	 * El CSS se carga la primera vez que se abre el formulario, no en cada
141	 * página: en la inmensa mayoría nadie llega a pulsar el botón.
142	 */
143	var stylesPromise = null;
144	function loadStyles() {
145		if (stylesPromise) { return stylesPromise; }
146
147		stylesPromise = new Promise(function (resolve) {
148			if (!CFG || !CFG.cssUrl) { resolve(); return; }
149
150			var link = document.createElement('link');
151			link.rel = 'stylesheet';
152			link.href = CFG.cssUrl;
153			link.onload = resolve;
154			link.onerror = resolve;
155			document.head.appendChild(link);
156
157			// Nunca dejar al cliente esperando por una hoja de estilos.
158			setTimeout(resolve, 1500);
159		});
160
161		return stylesPromise;
162	}
163
164	// ───────────────────────────────────────────────────────────
165	//  1. Detección del botón
166	// ───────────────────────────────────────────────────────────
167
168	/**
169	 * Solo mesas VIP. Deja fuera los botones de barcos y de cena + fiesta,
170	 * que comparten la clase wti-button-vip pero llevan otro texto.
171	 */
172	var RE_IS_VIP = /reservas\s*V\.?\s*I\.?\s*P/i;
173
174	function isWhatsAppLink(href) {
175		return /(?:wa\.me|api\.whatsapp\.com|web\.whatsapp\.com)/i.test(href || '');
176	}
177
178	/** Texto prerellenado del enlace de WhatsApp, ya decodificado. */
179	function readMessage(href) {
180		var m = /[?&]text=([^&]*)/i.exec(href || '');
181		if (!m) { return ''; }
182		try {
183			return decodeURIComponent(m[1].replace(/\+/g, ' '));
184		} catch (e) {
185			return m[1];
186		}
187	}
188
189	/**
190	 * Saca evento, fecha y sala del mensaje del enlace.
191	 * Formato de origen: "… el día 01-07-2026 en NOMBRE FIESTA (SALA)"
192	 *
193	 * Si un día wti-core añade atributos de datos limpios (data-wti-event,
194	 * data-wti-date, data-wti-venue), se usan esos con preferencia.
195	 */
196	function parseContext(link) {
197		var ctx = { event: '', date: '', dateRaw: '', venue: '' };
198
199		if (link.dataset && link.dataset.wtiEvent) {
200			ctx.event = link.dataset.wtiEvent;
201			ctx.dateRaw = link.dataset.wtiDate || '';
202			ctx.venue = link.dataset.wtiVenue || '';
203		} else {
204			var msg = readMessage(link.getAttribute('href'));
205			var m = /el\s+d[íi]a\s+(\d{1,2}[-/]\d{1,2}[-/]\d{4})\s+en\s+(.+)$/i.exec(msg);
206			if (m) {
207				ctx.dateRaw = m[1];
208				var rest = m[2].trim();
209				// La sala va entre paréntesis al final. A veces vienen dobles.
210				var v = /^(.*?)\s*\(+\s*([^()]+?)\s*\)+\s*$/.exec(rest);
211				if (v) {
212					ctx.event = v[1].trim();
213					ctx.venue = v[2].trim();
214				} else {
215					ctx.event = rest;
216				}
217			}
218		}
219
220		ctx.date = prettyDate(ctx.dateRaw);
221		return ctx;
222	}
223
224	/** "01-07-2026" → "1 de julio de 2026" / "July 1, 2026" */
225	function prettyDate(raw) {
226		var m = /^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/.exec(raw || '');
227		if (!m) { return raw || ''; }
228		var d = parseInt(m[1], 10);
229		var mo = parseInt(m[2], 10);
230		if (mo < 1 || mo > 12 || !MESES[mo - 1]) { return raw; }
231
232		return String(T.dateFormat || '%1$d de %2$s de %3$d')
233			.replace('%1$d', d)
234			.replace('%2$s', MESES[mo - 1])
235			.replace('%3$d', m[3]);
236	}
237
238	// ───────────────────────────────────────────────────────────
239	//  2. Modal
240	// ───────────────────────────────────────────────────────────
241
242	var overlay = null;
243	var lastFocus = null;
244
245	function el(tag, cls, text) {
246		var n = document.createElement(tag);
247		if (cls) { n.className = cls; }
248		if (text !== undefined) { n.textContent = text; }
249		return n;
250	}
251
252	function field(labelText, control, hintText) {
253		var wrap = el('div', 'wti-mvip-field');
254		var lab = el('label', 'wti-mvip-label', labelText);
255
256		// Un grupo de botones no es un campo etiquetable: se enlaza por
257		// aria-labelledby en vez de por "for", que ahí no vale.
258		if ('DIV' === control.tagName) {
259			lab.id = control.id + '-label';
260			control.setAttribute('aria-labelledby', lab.id);
261		} else {
262			lab.setAttribute('for', control.id);
263		}
264
265		wrap.appendChild(lab);
266		wrap.appendChild(control);
267		if (hintText) { wrap.appendChild(el('p', 'wti-mvip-hint', hintText)); }
268		wrap.appendChild(el('p', 'wti-mvip-error'));
269		return wrap;
270	}
271
272	/**
273	 * Grupo de botones excluyentes (chicos / chicas / mixto).
274	 * Devuelve { wrap, first, value() }.
275	 */
276	function pills(id, options) {
277		var wrap = el('div', 'wti-mvip-pills');
278		wrap.id = id;
279		wrap.setAttribute('role', 'radiogroup');
280
281		var chosen = '';
282		var buttons = [];
283
284		options.forEach(function (label) {
285			var b = el('button', 'wti-mvip-pill', label);
286			b.type = 'button';
287			b.setAttribute('role', 'radio');
288			b.setAttribute('aria-checked', 'false');
289			b.addEventListener('click', function () {
290				chosen = label;
291				buttons.forEach(function (other) {
292					var on = other === b;
293					other.classList.toggle('is-on', on);
294					other.setAttribute('aria-checked', on ? 'true' : 'false');
295				});
296			});
297			buttons.push(b);
298			wrap.appendChild(b);
299		});
300
301		return {
302			wrap: wrap,
303			first: buttons[0],
304			value: function () { return chosen; }
305		};
306	}
307
308	function input(id, type, attrs) {
309		var i = document.createElement(type === 'textarea' ? 'textarea' : 'input');
310		i.id = id;
311		i.className = 'wti-mvip-input';
312		if (type !== 'textarea') { i.type = type; }
313		Object.keys(attrs || {}).forEach(function (k) { i.setAttribute(k, attrs[k]); });
314		return i;
315	}
316
317	function buildModal(ctx) {
318		overlay = el('div', 'wti-mvip-overlay');
319		overlay.setAttribute('role', 'dialog');
320		overlay.setAttribute('aria-modal', 'true');
321		overlay.setAttribute('aria-label', T.title);
322		overlay.setAttribute('lang', LANG);
323
324		var panel = el('div', 'wti-mvip-panel');
325
326		// ── Cabecera
327		var head = el('div', 'wti-mvip-head');
328		head.appendChild(el('h2', 'wti-mvip-title', T.title));
329		var close = el('button', 'wti-mvip-close');
330		close.type = 'button';
331		close.setAttribute('aria-label', T.close);
332		close.innerHTML = '&times;';
333		close.addEventListener('click', closeModal);
334		head.appendChild(close);
335		panel.appendChild(head);
336
337		var body = el('div', 'wti-mvip-body');
338
339		if (CFG.isPreview) {
340			body.appendChild(el('div', 'wti-mvip-preview', T.preview));
341		}
342
343		// ── Resumen del evento (viene del botón, no se pide)
344		if (ctx.event || ctx.date || ctx.venue) {
345			var sum = el('div', 'wti-mvip-summary');
346			[[T.event, ctx.event], [T.date, ctx.date], [T.venue, ctx.venue]].forEach(function (row) {
347				if (!row[1]) { return; }
348				var line = el('div', 'wti-mvip-summary-row');
349				line.appendChild(el('span', 'wti-mvip-summary-key', row[0]));
350				line.appendChild(el('span', 'wti-mvip-summary-val', row[1]));
351				sum.appendChild(line);
352			});
353			body.appendChild(sum);
354		}
355
356		if (T.intro) { body.appendChild(el('p', 'wti-mvip-intro', T.intro)); }
357
358		var form = el('form', 'wti-mvip-form');
359		form.noValidate = true;
360
361		// ── Personas + edades (en dos columnas)
362		var people = input('wti-mvip-people', 'number', {
363			min: '1', max: String(CFG.maxPeople), inputmode: 'numeric', placeholder: '2'
364		});
365		var ages = document.createElement('select');
366		ages.id = 'wti-mvip-ages';
367		ages.className = 'wti-mvip-input';
368		var ph = el('option', null, T.agesPick);
369		ph.value = '';
370		ages.appendChild(ph);
371		AGES.forEach(function (r) {
372			var o = el('option', null, r);
373			o.value = r;
374			ages.appendChild(o);
375		});
376
377		var rowA = el('div', 'wti-mvip-row');
378		rowA.appendChild(field(T.people, people));
379		rowA.appendChild(field(T.ages, ages));
380		form.appendChild(rowA);
381
382		// ── Tipo de grupo: chicos / chicas / mixto.
383		// Son tres opciones y van en botones, no en desplegable: en el móvil
384		// es un toque en vez de tres, y se ve de un vistazo lo que hay.
385		var group = pills('wti-mvip-group', GROUPS);
386		form.appendChild(field(T.group, group.wrap));
387
388		// ── Nombre + apellido
389		var first = input('wti-mvip-first', 'text', { autocomplete: 'given-name' });
390		var last = input('wti-mvip-last', 'text', { autocomplete: 'family-name' });
391		var rowB = el('div', 'wti-mvip-row');
392		rowB.appendChild(field(T.firstName, first));
393		rowB.appendChild(field(T.lastName, last));
394		form.appendChild(rowB);
395
396		// ── Email
397		var email = input('wti-mvip-email', 'email', {
398			autocomplete: 'email', placeholder: T.emailPh || '[email protected]'
399		});
400		form.appendChild(field(T.email, email));
401
402		// ── Teléfono
403		//
404		// Hay que preguntarlo: el mensaje lo escribe el navegador y NO puede
405		// saber desde qué número lo enviará después el cliente. Sin este campo
406		// la solicitud llegaría sin teléfono en el texto.
407		var phone = input('wti-mvip-phone-num', 'tel', {
408			autocomplete: 'tel', placeholder: T.phonePh || ''
409		});
410		form.appendChild(field(T.phone, phone));
411
412		// ── Comentario
413		var comment = input('wti-mvip-comment', 'textarea', {
414			rows: '3', maxlength: String(CFG.commentMax)
415		});
416		form.appendChild(field(T.comment, comment));
417
418		// ── Aviso de consumo mínimo
419		if (T.note) { form.appendChild(el('p', 'wti-mvip-note', T.note)); }
420
421		// ── Privacidad
422		//
423		// Dos formas, según los ajustes:
424		//  · casilla obligatoria (hay que marcarla para enviar);
425		//  · aviso informativo con enlace, sin marcar nada.
426		//
427		// Gestionar la solicitud se apoya en la propia petición del cliente,
428		// no en su consentimiento, así que la casilla no es obligatoria por
429		// ley; informar sí. La casilla de novedades es aparte y esa sí es
430		// consentimiento, por eso nunca bloquea.
431		var privacy;
432		if (CFG.privacyMode === 'notice') {
433			form.appendChild(privacyNotice());
434			privacy = { box: { checked: true }, notice: true };
435		} else {
436			privacy = checkRow('wti-mvip-privacy', T.privacy, T.privacyUrl);
437			form.appendChild(privacy.wrap);
438		}
439
440		var marketing = null;
441		if (T.marketing) {
442			marketing = checkRow('wti-mvip-marketing', T.marketing, '');
443			form.appendChild(marketing.wrap);
444		}
445
446		// ── Botón de envío
447		var submit = el('button', 'wti-mvip-submit', T.submit);
448		submit.type = 'submit';
449		form.appendChild(submit);
450
451		var done = el('p', 'wti-mvip-done', T.success);
452		form.appendChild(done);
453
454		form.addEventListener('submit', function (ev) {
455			ev.preventDefault();
456			var data = {
457				people: people.value.trim(),
458				ages: ages.value,
459				group: group.value(),
460				first: first.value.trim(),
461				last: last.value.trim(),
462				email: email.value.trim(),
463				phone: phone.value.trim(),
464				comment: comment.value.trim(),
465				privacy: privacy.box.checked,
466				marketing: marketing ? marketing.box.checked : null
467			};
468			var controls = {
469				people: people, ages: ages, group: group.first,
470				first: first, last: last, email: email, phone: phone
471			};
472			if (!validate(form, data, controls, privacy)) {
473				return;
474			}
475			openWhatsApp(ctx, data);
476			overlay.classList.add('is-sent');
477			done.classList.add('is-visible');
478		});
479
480		body.appendChild(form);
481		panel.appendChild(body);
482		overlay.appendChild(panel);
483
484		overlay.addEventListener('mousedown', function (ev) {
485			if (ev.target === overlay) { closeModal(); }
486		});
487
488		return overlay;
489	}
490
491	/** Aviso de privacidad con enlace, sin casilla que marcar. */
492	function privacyNotice() {
493		var p = el('p', 'wti-mvip-privacy-notice', T.privacyNotice || '');
494		if (T.privacyUrl) {
495			p.appendChild(document.createTextNode(' '));
496			var a = document.createElement('a');
497			a.href = T.privacyUrl;
498			a.target = '_blank';
499			a.rel = 'noopener';
500			a.textContent = T.privacyLink || 'Política de Privacidad';
501			p.appendChild(a);
502		}
503		return p;
504	}
505
506	function checkRow(id, labelText, url) {
507		var wrap = el('div', 'wti-mvip-check');
508		var box = document.createElement('input');
509		box.type = 'checkbox';
510		box.id = id;
511		var lab = document.createElement('label');
512		lab.setAttribute('for', id);
513		lab.textContent = labelText;
514		if (url) {
515			lab.appendChild(document.createTextNode(' '));
516			var a = document.createElement('a');
517			a.href = url;
518			a.target = '_blank';
519			a.rel = 'noopener';
520			a.textContent = '↗';
521			lab.appendChild(a);
522		}
523		wrap.appendChild(box);
524		wrap.appendChild(lab);
525		wrap.appendChild(el('p', 'wti-mvip-error'));
526		return { wrap: wrap, box: box };
527	}
528
529	// ───────────────────────────────────────────────────────────
530	//  3. Validación
531	// ───────────────────────────────────────────────────────────
532
533	function markError(control, message) {
534		var wrap = control.closest('.wti-mvip-field') || control.closest('.wti-mvip-check');
535		if (!wrap) { return; }
536		wrap.classList.add('has-error');
537		var p = wrap.querySelector('.wti-mvip-error');
538		if (p) { p.textContent = message; }
539	}
540
541	function clearErrors(form) {
542		form.querySelectorAll('.has-error').forEach(function (n) {
543			n.classList.remove('has-error');
544			var p = n.querySelector('.wti-mvip-error');
545			if (p) { p.textContent = ''; }
546		});
547	}
548
549	function validate(form, data, controls, privacy) {
550		clearErrors(form);
551		var ok = true;
552		var firstBad = null;
553
554		function fail(control, msg) {
555			markError(control, msg);
556			if (!firstBad) { firstBad = control; }
557			ok = false;
558		}
559
560		var n = parseInt(data.people, 10);
561		if (!data.people || isNaN(n) || n < 1 || n > CFG.maxPeople) {
562			fail(controls.people, T.peopleBad);
563		}
564		if (!data.ages) { fail(controls.ages, T.required); }
565		if (GROUPS.length && !data.group) { fail(controls.group, T.required); }
566		if (!data.first) { fail(controls.first, T.required); }
567		if (!data.last) { fail(controls.last, T.required); }
568		if (!data.email || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(data.email)) {
569			fail(controls.email, T.emailBad);
570		}
571		// Teléfono: al menos 8 cifras. No se valida el país — la mitad de los
572		// clientes son extranjeros y cada país tiene su formato.
573		if (String(data.phone).replace(/\D+/g, '').length < 8) {
574			fail(controls.phone, T.phoneBad);
575		}
576		// Con el aviso informativo no hay casilla que marcar: nada que validar.
577		if (!privacy.notice && !data.privacy) { fail(privacy.box, T.privacyBad); }
578
579		if (firstBad) { firstBad.focus(); }
580		return ok;
581	}
582
583	// ───────────────────────────────────────────────────────────
584	//  4. Mensaje de WhatsApp
585	// ───────────────────────────────────────────────────────────
586
587	/**
588	 * Mismo formato que la notificación del asistente, para que las dos vías
589	 * lleguen igual y se puedan reenviar a la sala sin retocar nada.
590	 */
591	function buildMessage(ctx, data) {
592		var lines = [];
593
594		function add(key, val) {
595			if (val !== '' && val !== null && val !== undefined) {
596				lines.push(key + ': ' + val);
597			}
598		}
599
600		if (T.greeting) { lines.push(T.greeting, ''); }
601
602		add(T.msgName || T.firstName, data.first + ' ' + data.last);
603		add(T.email, data.email);
604		add(T.msgPhone || T.phone, data.phone);
605		add(T.event, ctx.event);
606		add(T.date, ctx.date);
607		add(T.venue, ctx.venue);
608		add(T.people, data.people);
609
610		// "10 chicos de 25-35" — una sola línea, como la del asistente.
611		if (data.group || data.ages) {
612			add(T.msgGroup, String(T.groupFormat || '%1$s %2$s de %3$s')
613				.replace('%1$s', data.people)
614				.replace('%2$s', String(data.group || '').toLowerCase())
615				.replace('%3$s', data.ages)
616				.replace(/\s{2,}/g, ' ')
617				.trim());
618		}
619
620		if (data.comment) { add(T.msgComment || T.commentKey, data.comment); }
621		if (data.marketing !== null) {
622			add(T.msgOptIn, data.marketing ? T.msgYes : T.msgNo);
623		}
624
625		if (T.closing) { lines.push('', T.closing); }
626
627		return lines.join('\n');
628	}
629
630	function openWhatsApp(ctx, data) {
631		var url = 'https://wa.me/' + CFG.phone + '?text=' + encodeURIComponent(buildMessage(ctx, data));
632		// Dentro del gesto de clic: el navegador no lo bloquea.
633		window.open(url, '_blank', 'noopener');
634	}
635
636	// ───────────────────────────────────────────────────────────
637	//  5. Abrir / cerrar
638	// ───────────────────────────────────────────────────────────
639
640	function openModal(ctx) {
641		closeModal();
642		lastFocus = document.activeElement;
643		document.body.appendChild(buildModal(ctx));
644		document.body.classList.add('wti-mvip-open');
645		requestAnimationFrame(function () { overlay.classList.add('is-visible'); });
646		var first = overlay.querySelector('input, select, textarea, button');
647		if (first) { first.focus(); }
648	}
649
650	function closeModal() {
651		if (!overlay) { return; }
652		overlay.remove();
653		overlay = null;
654		document.body.classList.remove('wti-mvip-open');
655		if (lastFocus && lastFocus.focus) { lastFocus.focus(); }
656	}
657
658	document.addEventListener('keydown', function (ev) {
659		if ('Escape' === ev.key && overlay) { closeModal(); }
660	});
661
662	/** ¿Es este enlace el botón de MESAS VIP? */
663	function isVipLink(link) {
664		if (!link) { return false; }
665		var href = link.getAttribute('href') || '';
666		return isWhatsAppLink(href) && RE_IS_VIP.test(readMessage(href));
667	}
668
669	function closestLink(target) {
670		return target && target.closest ? target.closest('a') : null;
671	}
672
673	// Al acercarse al botón se va pidiendo la configuración, para que al
674	// pulsar no haya que esperar a nada. Quien no lo toca, no gasta nada.
675	['pointerenter', 'touchstart'].forEach(function (evName) {
676		document.addEventListener(evName, function (ev) {
677			if (isVipLink(closestLink(ev.target))) { loadConfig(); }
678		}, { capture: true, passive: true });
679	});
680
681	// Delegación: el calendario puede repintar los botones por AJAX.
682	document.addEventListener('click', function (ev) {
683		var link = closestLink(ev.target);
684		if (!isVipLink(link)) { return; }
685
686		// Se para el enlace SIEMPRE y se decide después. Si resulta que el
687		// formulario no está activo —o el servidor no contesta— se sigue al
688		// WhatsApp de siempre, que es lo que el enlace hacía.
689		ev.preventDefault();
690		ev.stopPropagation();
691
692		var href = link.getAttribute('href');
693
694		loadConfig().then(function (cfg) {
695			if (!cfg) {
696				window.location.href = href;
697				return;
698			}
699			// El contexto se lee DESPUÉS de tener la configuración: la fecha
700			// se escribe en el idioma que toque, y los meses vienen con ella.
701			var ctx = parseContext(link);
702			loadStyles().then(function () { openModal(ctx); });
703		});
704	}, true);
705
706})();

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.