PageSourceSearch

https://www.junipersardinia.com/wp-content/plugins/jnp-whatsapp/assets/js/widget.js?ver=1.0

js junipersardinia.com collected 2026-10-09 23:14:16 UTC 23,298 bytes, 584 lines download raw bytes

1/* jnp-whatsapp widget — frontend */
2(function () {
3	'use strict';
4
5	const API     = jnpWa.restUrl;
6	const i18n    = jnpWa.i18n || {};
7	const NONCE   = jnpWa.nonce;
8	const STRINGS = jnpWa.strings || {};
9
10	// ── Session ID (persists in sessionStorage so page nav keeps context) ──────
11	function getSessionId() {
12	let id = sessionStorage.getItem('jnp_wa_session');
13	if (!id) {
14		id = 'ws_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
15		sessionStorage.setItem('jnp_wa_session', id);
16	}
17	return id;
18	}
19
20	// ── State ──────────────────────────────────────────────────────────────────
21	const state = {
22	open:      false,
23	loading:   false,
24	linked:    false,          // phone number confirmed
25	messages:  [],
26	sessionId: getSessionId(),
27	};
28
29	// ── DOM refs ───────────────────────────────────────────────────────────────
30	let widget, bubble, panel, messagesEl, inputEl, sendBtn, phoneBar, phoneInput;
31
32	// ── Build DOM ──────────────────────────────────────────────────────────────
33	function buildWidget() {
34	widget = document.createElement('div');
35	widget.id = 'jnp-wa-widget';
36	widget.setAttribute('aria-live', 'polite');
37
38	widget.innerHTML = `
39		<div id="jnp-wa-bubble" role="button" tabindex="0" aria-label="Open chat">
40		<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
41			<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
42		</svg>
43		<svg id="jnp-wa-close-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
44			<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
45		</svg>
46		</div>
47
48		<div id="jnp-wa-panel" role="dialog" aria-label="Juniper Club chat" aria-modal="true">
49		<div id="jnp-wa-header">
50			<div id="jnp-wa-header-info">
51			<div id="jnp-wa-avatar" aria-hidden="true">JC</div>
52			<div>
53				<div id="jnp-wa-header-name">Juniper Club</div>
54				<div id="jnp-wa-header-status">
55				<span id="jnp-wa-status-dot" aria-hidden="true"></span>
56				<span id="jnp-wa-status-text">Online</span>
57				</div>
58			</div>
59			</div>
60		</div>
61
62		<div id="jnp-wa-messages" role="log" aria-label="Conversation"></div>
63
64		<div id="jnp-wa-phone-bar">
65			<p id="jnp-wa-phone-label">Continue on WhatsApp — share your number and we'll pick up there too.</p>
66			<div id="jnp-wa-phone-row">
67			<input id="jnp-wa-phone-input" type="tel" placeholder="+39 345 678 9012" aria-label="Your WhatsApp number" autocomplete="tel">
68			<button id="jnp-wa-phone-btn" aria-label="Link WhatsApp number">Link</button>
69			</div>
70			<button id="jnp-wa-phone-skip">Skip</button>
71		</div>
72
73		<div id="jnp-wa-composer">
74			<textarea id="jnp-wa-input" rows="1" placeholder="Type a message…" aria-label="Message" autocomplete="off"></textarea>
75			<button id="jnp-wa-send" aria-label="Send message" disabled>
76			<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
77				<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
78			</svg>
79			</button>
80		</div>
81		</div>
82	`;
83
84	document.body.appendChild(widget);
85
86	bubble    = widget.querySelector('#jnp-wa-bubble');
87	panel     = widget.querySelector('#jnp-wa-panel');
88	messagesEl= widget.querySelector('#jnp-wa-messages');
89	inputEl   = widget.querySelector('#jnp-wa-input');
90	sendBtn   = widget.querySelector('#jnp-wa-send');
91	phoneBar  = widget.querySelector('#jnp-wa-phone-bar');
92	phoneInput= widget.querySelector('#jnp-wa-phone-input');
93
94	bindEvents();
95	}
96
97	// ── Events ─────────────────────────────────────────────────────────────────
98	function bindEvents() {
99	bubble.addEventListener('click', togglePanel);
100	bubble.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') togglePanel(); });
101
102	sendBtn.addEventListener('click', submitMessage);
103	inputEl.addEventListener('keydown', e => {
104		if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submitMessage(); }
105	});
106	inputEl.addEventListener('input', () => {
107		sendBtn.disabled = inputEl.value.trim() === '';
108		autoResize();
109	});
110
111	// Phone bar
112	widget.querySelector('#jnp-wa-phone-btn').addEventListener('click', linkPhone);
113	widget.querySelector('#jnp-wa-phone-skip').addEventListener('click', () => {
114		phoneBar.style.display = 'none';
115	});
116	phoneInput.addEventListener('keydown', e => { if (e.key === 'Enter') linkPhone(); });
117
118	// Close on outside click
119	document.addEventListener('click', e => {
120		if (state.open && !widget.contains(e.target)) closePanel();
121	});
122	document.addEventListener('keydown', e => { if (e.key === 'Escape' && state.open) closePanel(); });
123	}
124
125	function autoResize() {
126	inputEl.style.height = 'auto';
127	inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
128	}
129
130	// ── Panel open / close ─────────────────────────────────────────────────────
131	function togglePanel() { state.open ? closePanel() : openPanel(); }
132
133	function openPanel(intent) {
134		state.open = true;
135		panel.classList.add('jnp-wa-open');
136		widget.querySelector('#jnp-wa-close-icon').style.display = 'block';
137		widget.querySelector('svg:first-child').style.display = 'none';
138		bubble.setAttribute('aria-expanded', 'true');
139
140		// Lock body scroll (same as Bootstrap modal)
141		state._scrollY = window.scrollY;
142		document.body.style.overflow = 'hidden';
143		document.body.style.position = 'fixed';
144		document.body.style.top = '-' + state._scrollY + 'px';
145		document.body.style.width = '100%';
146
147		if (state.messages.length === 0) {
148			showGreeting(intent);
149		} else if (intent) {
150			injectIntent(intent);
151		}
152
153		setTimeout(() => inputEl.focus(), 300);
154	}
155
156	function closePanel() {
157		state.open = false;
158		panel.classList.remove('jnp-wa-open');
159		widget.querySelector('#jnp-wa-close-icon').style.display = 'none';
160		widget.querySelector('svg:first-child').style.display = 'block';
161		bubble.setAttribute('aria-expanded', 'false');
162
163		// Restore body scroll
164		document.body.style.overflow = '';
165		document.body.style.position = '';
166		document.body.style.top = '';
167		document.body.style.width = '';
168		window.scrollTo(0, state._scrollY || 0);
169	}
170
171	// ── Greeting & intent injection ────────────────────────────────────────────
172	const intentMessages = {
173	reservation: "Hi! I'd like to book a table.",
174	tickets:     "Hi! I'd like to buy tickets for an event.",
175	info:        "Hi! I have a question about the club.",
176	vip:         "Hi! I'm interested in a VIP table package.",
177	};
178
179	function showGreeting(intent) {
180	const greet = STRINGS.greeting || "Hi! 👋 I'm the Juniper Club assistant. How can I help you tonight?";
181	appendMessage('bot', greet);
182	if (intent && intentMessages[intent]) {
183		setTimeout(() => {
184		inputEl.value = intentMessages[intent];
185		sendBtn.disabled = false;
186		autoResize();
187		inputEl.focus();
188		}, 600);
189	}
190	}
191
192	function injectIntent(intent) {
193	if (intent && intentMessages[intent]) {
194		inputEl.value = intentMessages[intent];
195		sendBtn.disabled = false;
196		autoResize();
197	}
198	}
199
200	// ── Send message ───────────────────────────────────────────────────────────
201	async function submitMessage() {
202	const text = inputEl.value.trim();
203	if (!text || state.loading) return;
204
205	inputEl.value = '';
206	inputEl.style.height = 'auto';
207	sendBtn.disabled = true;
208	appendMessage('user', text);
209	showTyping();
210
211	state.loading = true;
212
213	try {
214		const res = await fetch(API + 'jnp-wa/v1/chat', {
215		method:  'POST',
216		headers: {
217			'Content-Type': 'application/json',
218			'X-WP-Nonce':   NONCE,
219		},
220		body: JSON.stringify({
221			session_id: state.sessionId,
222			message:    text,
223			lang:       jnpWa.lang || 'it',
224		}),
225		});
226
227		const data = await res.json();
228		hideTyping();
229
230		if (data.reply) {
231			appendMessage('bot', data.reply);
232			if (data.action) renderCard(data.action);
233		}
234	} catch (err) {
235		hideTyping();
236		appendMessage('bot', "Sorry, I couldn't connect. Please try again.");
237	}
238
239	state.loading = false;
240	}
241
242	// ── Phone linking ──────────────────────────────────────────────────────────
243	async function linkPhone() {
244	const num = phoneInput.value.trim();
245	if (!num) return;
246
247	phoneInput.disabled = true;
248	widget.querySelector('#jnp-wa-phone-btn').disabled = true;
249
250	try {
251		const res = await fetch(API + 'jnp-wa/v1/link-phone', {
252		method:  'POST',
253		headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': NONCE },
254		body: JSON.stringify({ session_id: state.sessionId, phone: num }),
255		});
256		const data = await res.json();
257
258		if (data.success) {
259		state.linked = true;
260		phoneBar.innerHTML = '<p id="jnp-wa-phone-label" style="text-align:center;">✅ Linked! Continue this conversation on WhatsApp anytime.</p>';
261		setTimeout(() => { phoneBar.style.display = 'none'; }, 3000);
262		} else {
263		phoneInput.disabled = false;
264		widget.querySelector('#jnp-wa-phone-btn').disabled = false;
265		phoneInput.placeholder = data.message || 'Invalid number, try again';
266		}
267	} catch {
268		phoneInput.disabled = false;
269		widget.querySelector('#jnp-wa-phone-btn').disabled = false;
270	}
271	}
272
273	// ── Message rendering ──────────────────────────────────────────────────────
274	function appendMessage(role, text) {
275	const div = document.createElement('div');
276	div.className = 'jnp-wa-msg jnp-wa-msg-' + role;
277	div.innerHTML = '<p>' + escHtml(text).replace(/\n/g, '<br>').replace(/\*(.*?)\*/g, '<strong>$1</strong>') + '</p>';
278	messagesEl.appendChild(div);
279	messagesEl.scrollTop = messagesEl.scrollHeight;
280	state.messages.push({ role, text });
281	}
282
283	function showTyping() {
284	const div = document.createElement('div');
285	div.className = 'jnp-wa-msg jnp-wa-msg-bot jnp-wa-typing';
286	div.id = 'jnp-wa-typing';
287	div.innerHTML = '<span></span><span></span><span></span>';
288	messagesEl.appendChild(div);
289	messagesEl.scrollTop = messagesEl.scrollHeight;
290	}
291
292	function hideTyping() {
293	const el = document.getElementById('jnp-wa-typing');
294	if (el) el.remove();
295	}
296
297	function escHtml(s) {
298	return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
299	}
300
301	// ── Public trigger API ─────────────────────────────────────────────────────
302	// Called by data-jnp-chat attributes and jnp_wa_chat_trigger() PHP helper
303	function handleTriggerClick(e) {
304	const el     = e.currentTarget;
305	const intent = el.dataset.jnpIntent || null;
306	openPanel(intent);
307	}
308
309	function attachTriggers() {
310	document.querySelectorAll('[data-jnp-chat]').forEach(el => {
311		el.addEventListener('click', handleTriggerClick);
312	});
313	}
314
315	// ── Init ───────────────────────────────────────────────────────────────────
316	function init() {
317	buildWidget();
318	attachTriggers();
319
320	// Re-attach after Bricks / Elementor / AJAX page updates
321	document.addEventListener('jnp_page_updated', attachTriggers);
322	}
323
324	if (document.readyState === 'loading') {
325	document.addEventListener('DOMContentLoaded', init);
326	} else {
327	init();
328	}
329
330	// Expose for external calls (e.g. from custom JS)
331	window.jnpChat = {
332	open:   (intent) =>
332 openPanel(intent),
333	close:  closePanel,
334	toggle: togglePanel,
335	};
336
337
338	// ── Cards system ──────────────────────────────────────────────────────────────
339
340	let cardsData = null;
341
342	async function loadCardsData() {
343		// Always fetch fresh — ensures i18n is applied correctly
344		try {
345			const res  = await fetch(API + 'jnp-wa/v1/cards', {
346				headers: { 'X-WP-Nonce': NONCE },
347			});
348			cardsData = await res.json();
349		} catch (e) {
350			cardsData = {};
351		}
352		return cardsData;
353	}
354
355	async function renderCard(action) {
356		const data = await loadCardsData();
357		if (!data) return;
358
359		switch (action) {
360			case 'show_hours':      appendHoursCard(data.hours);        break;
361			case 'show_venue':      appendVenueCard(data.venue);        break;
362			case 'show_events':     appendEventsCard(data.events, data.ev_settings); break;
363			case 'show_contact_form': appendContactForm(data.contact_form); break;
364		}
365	}
366
367	function appendHoursCard(hours) {
368		if (!hours || !hours.enabled) return;
369		const days = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
370		const labels = i18n.days || {monday:'Mon',tuesday:'Tue',wednesday:'Wed',thursday:'Thu',friday:'Fri',saturday:'Sat',sunday:'Sun'};
371		const todayIdx = (new Date().getDay() + 6) % 7;
372		const todayKey = days[todayIdx];
373
374		let rows = '';
375		days.forEach((day, i) => {
376			const d = hours.days[day] || {};
377			const isToday = day === todayKey;
378			const isClosed  = d.closed || !d.open;
379			const dayLabel  = (!isClosed && d.date) ? `${labels[day]} ${escHtml(d.date)}` : labels[day];
380			const todayBadge = isToday ? ` <span class="jnp-wa-badge">${i18n.today||'Today'}</span>` : '';
381			const evBadge   = (!isClosed && d.event) ? ` <span class="jnp-wa-badge jnp-wa-badge-event">${escHtml(d.event)}</span>` : '';
382			rows += `<div class="jnp-wa-card-row${isToday ? ' jnp-wa-today' : ''}${isToday && isClosed ? ' jnp-wa-closed-today' : ''}">
383				<span class="jnp-wa-card-day">${dayLabel}${todayBadge}${evBadge}</span>
384				<span class="jnp-wa-card-time">${isClosed ? (i18n.closed||'Closed') : `${d.open} – ${d.close || '05:00'}`}</span>
385			</div>`;
386		});
387
388		const note = hours.note ? `<p class="jnp-wa-card-note">${escHtml(hours.note)}</p>` : '';
389		const lastEntry = hours.last_entry ? `<p class="jnp-wa-card-note">Last entry: ${escHtml(hours.last_entry)}</p>` : '';
390
391		appendCard(`<div class="jnp-wa-ic">
392			<div class="jnp-wa-ic-head">🕙 ${i18n.opening_hours||'Opening hours'}</div>
393			${rows}
394			${note}${lastEntry}
395		</div>`);
396	}
397
398	function appendVenueCard(venue) {
399		if (!venue || !venue.enabled) return;
400		const rows = [
401			venue.address ? `<div class="jnp-wa-card-row"><span class="jnp-wa-card-day">📍 Address</span><span class="jnp-wa-card-time">${escHtml(venue.address)}${venue.city ? ', ' + escHtml(venue.city) : ''}</span></div>` : '',
402			venue.phone   ? `<div class="jnp-wa-card-row"><span class="jnp-wa-card-day">📞 Phone</span><span class="jnp-wa-card-time">${escHtml(venue.phone)}</span></div>` : '',
403			venue.email   ? `<div class="jnp-wa-card-row"><span class="jnp-wa-card-day">✉️ Email</span><span class="jnp-wa-card-time">${escHtml(venue.email)}</span></div>` : '',
404			venue.instagram ? `<div class="jnp-wa-card-row"><span class="jnp-wa-card-day">📸 Instagram</span><span class="jnp-wa-card-time">${escHtml(venue.instagram)}</span></div>` : '',
405			venue.dress_code ? `<div class="jnp-wa-card-row"><span class="jnp-wa-card-day">👔 Dress code</span><span class="jnp-wa-card-time">${escHtml(venue.dress_code)}</span></div>` : '',
406			venue.age_minimum ? `<div class="jnp-wa-card-row"><span class="jnp-wa-card-day">🪪 Min age</span><span class="jnp-wa-card-time">${escHtml(venue.age_minimum)}</span></div>` : '',
407		].filter(Boolean).join('');
408
409		const mapsBtn = venue.maps_url
410			? `<a href="${escAttr(venue.maps_url)}" target="_blank" class="jnp-wa-card-btn">Get directions →</a>`
411			: '';
412
413		appendCard(`<div class="jnp-wa-ic">
414			<div class="jnp-wa-ic-head">
414📍 ${escHtml(venue.name || 'Juniper Club')}</div>
415			${rows}
416			${mapsBtn}
417		</div>`);
418	}
419
420	function appendEventsCard(events, settings) {
421		if (!events || !events.length) return;
422		const btnPrimary   = settings.btn_primary   || 'Get tickets';
423		const btnSecondary = settings.btn_secondary || 'Book a table';
424		const boxoffice    = 'juniperclub';
425
426		const cards = events.map(ev => {
427			const thumb = ev.thumb
428				? `<div class="jnp-wa-ev-thumb" style="background-image:url('${escAttr(ev.thumb)}')"></div>`
429				: `<div class="jnp-wa-ev-thumb jnp-wa-ev-thumb-placeholder"></div>`;
430
431			// ── Ticket button ──────────────────────────────────────────────
432			// Priority: TTWidget (tt_occurrence_id) → jnp-ticket-picker-btn → URL fallback
433			let ticketBtn = '';
434			if (ev.tt_occurrence_id) {
435				// TTWidget (TicketTailor) — same as event-card.php jnp-tt-ticket-btn
436				ticketsBtn = `<button class="jnp-wa-card-btn jnp-wa-ev-tt-btn"
437					data-boxoffice="${escAttr(boxoffice)}"
438					data-tt-id="${escAttr(ev.tt_occurrence_id)}"
439					data-href="${escAttr(ev.tickets_url)}">${escHtml(btnPrimary)}</button>`;
440				ticketsBtn = ticketsBtn; // rename fix below
441				ticketBtn = ticketsBtn;
442			} else {
443				// jnp-ticket-picker-btn — opens the quick events modal with occurrence pre-selected
444				// same pattern as quick-events-modal.php and event-card.php
445				ticketBtn = `<button class="jnp-wa-card-btn jnp-ticket-picker-btn"
446					data-event-id="${escAttr(String(ev.id))}"
447					data-occurrence-ts="${escAttr(String(ev.ts))}"
448					data-event-title="${escAttr(ev.title)}"
449					data-thumbnail="${escAttr(ev.thumb)}">${escHtml(btnPrimary)}</button>`;
450			}
451
452			// ── Tables button ──────────────────────────────────────────────
453			// If tt_tables_id present → TTWidget; else → open in-chat reservation flow
454			let tablesBtn = '';
455			if (ev.tt_tables_id) {
456				tablesBtn = `<button class="jnp-wa-card-btn jnp-wa-card-btn-outline jnp-wa-ev-tt-btn"
457					data-boxoffice="${escAttr(boxoffice)}"
458					data-tt-id="${escAttr(ev.tt_tables_id)}">${escHtml(btnSecondary)}</button>`;
459			} else {
460				// Fall back to in-chat reservation flow
461				tablesBtn = `<button class="jnp-wa-card-btn jnp-wa-card-btn-outline jnp-wa-ev-reserve-btn"
462					data-event="${escHtml(ev.title)}"
463					data-date="${escHtml(ev.date)}">${escHtml(btnSecondary)}</button>`;
464			}
465
466			return `<div class="jnp-wa-ev-card">
467				${thumb}
468				<div class="jnp-wa-ev-body">
469					<div class="jnp-wa-ev-title">${escHtml(ev.title)}</div>
470					<div class="jnp-wa-ev-date">${escHtml(ev.date)} · ${escHtml(ev.time)}</div>
471					<div class="jnp-wa-ev-btns">
472						${ticketBtn}
473						${tablesBtn}
474					</div>
475				</div>
476			</div>`;
477		}).join('');
478
479		const wrap = document.createElement('div');
480		wrap.className = 'jnp-wa-card-wrap';
481		wrap.innerHTML = `<div class="jnp-wa-ic"><div class="jnp-wa-ic-head">🗓 ${i18n.upcoming_events||'Upcoming events'}</div>${cards}</div>`;
482
483		// TTWidget buttons (when tt_occurrence_id is present)
484		wrap.querySelectorAll('.jnp-wa-ev-tt-btn').forEach(btn => {
485			btn.addEventListener('click', () => {
486				const bo   = btn.dataset.boxoffice;
487				const ttId = parseInt(btn.dataset.ttId, 10);
488				if (window.TTWidget && typeof TTWidget.loadEventOccurrence === 'function' && bo && ttId) {
489					TTWidget.loadEventOccurrence(bo, ttId);
490				} else if (btn.dataset.href) {
491					window.open(btn.dataset.href, '_blank');
492				}
493			});
494		});
495
496		// jnp-ticket-picker-btn — ticket-modal.js handles these via delegated events
497		// so no extra binding needed here. The buttons just need the correct
498		// data-event-id, data-occurrence-ts, data-event-title, data-thumbnail attributes.
499
500		// Bind in-chat reservation buttons
501		wrap.querySelectorAll('.jnp-wa-ev-reserve-btn').forEach(btn => {
502			btn.addEventListener('click', () => {
503				const eventName = btn.dataset.event;
504				const eventDate = btn.dataset.date;
505				// Pre-fill the input with a reservation request for this specific event
506				inputEl.value = `${i18n.reserve_prefix||'Vorrei prenotare un tavolo per'} ${eventName} (${eventDate})`;
507				sendBtn.disabled = false;
508				inputEl.focus();
509			});
510		});
511
512		messagesEl.appendChild(wrap);
513		messagesEl.scrollTop = messagesEl.scrollHeight;
514	}
515
516	function appendContactForm(cfg) {
517		if (!cfg || !cfg.enabled) return;
518		const fields = cfg.fields || ['name','phone','message'];
519		const fieldHtml = fields.map(f => {
520			if (f === 'message') {
521				return `<div class="jnp-wa-cf-field">
522					<label class="jnp-wa-cf-label">${f.charAt(0).toUpperCase()+f.slice(1)}</label>
523					<input class="jnp-wa-cf-input" type="text" data-field="${f}" placeholder="…">
524				</div>`;
525			}
526			const type = f === 'email' ? 'email' : f === 'phone' ? 'tel' : 'text';
527			return `<div class="jnp-wa-cf-field">
528				<label class="jnp-wa-cf-label">${f.charAt(0).toUpperCase()+f.slice(1)}</label>
529				<input class="jnp-wa-cf-input" type="${type}" data-field="${f}" placeholder="">
530			</div>`;
531		}).join('');
532
533		const formId = 'jnp-cf-' + Date.now();
534		appendCard(`<div class="jnp-wa-ic" id="${formId}">
535			<div class="jnp-wa-ic-head">✉️ ${escHtml(cfg.title || 'Contact us')}</div>
536			${fieldHtml}
537			<button class="jnp-wa-card-btn jnp-wa-cf-submit">Send →</button>
538			<p class="jnp-wa-cf-result"></p>
539		</div>`);
540
541		// Bind submit after DOM insertion
542		setTimeout(() => {
543			const form = document.getElementById(formId);
544			if (!form) return;
545			form.querySelector('.jnp-wa-cf-submit').addEventListener('click', async () => {
546				const payload = { session_id: state.sessionId };
547				form.querySelectorAll('.jnp-wa-cf-input').forEach(inp => {
548					payload[inp.dataset.field] = inp.value.trim();
549				});
550				const btn = form.querySelector('.jnp-wa-cf-submit');
551				btn.disabled = true;
552				btn.textContent = i18n.sending||'Sending…';
553				try {
554					const res  = await fetch(API + 'jnp-wa/v1/contact-submit', {
555						method:  'POST',
556						headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': NONCE },
557						body:    JSON.stringify(payload),
558					});
559					const data = await res.json();
560					form.querySelector('.jnp-wa-cf-result').textContent = data.message || i18n.sent || 'Sent!';
561					btn.style.display = 'none';
562				} catch {
563					btn.disabled    = false;
564					btn.textContent = i18n.send||'Send →';
565					form.querySelector('.jnp-wa-cf-result').textContent = i18n.send_error||'Error — please try again.';
566				}
567			});
568		}, 100);
569	}
570
571	function appendCard(html) {
572		const div = document.createElement('div');
573		div.className = 'jnp-wa-card-wrap';
574		div.innerHTML = html;
575		messagesEl.appendChild(div);
576		messagesEl.scrollTop = messagesEl.scrollHeight;
577	}
578
579	function escAttr(s) {
580		return String(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
581	}
582
583
584})();

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.