PageSourceSearch

https://www.junipersardinia.com/wp-content/themes/juniper-club/assets/js/jnp-ticket-sheet.js?ver=1.0

js junipersardinia.com collected 2026-10-09 23:14:18 UTC 40,574 bytes, 923 lines download raw bytes

1/**
2 * jnp-ticket-sheet.js
3 * Standalone ticket purchase bottom sheet — same UX as the PR microsite.
4 * Triggered by elements with class .jnp-ticket-picker-btn
5 * Required data attributes: data-event-id, data-occurrence-ts, data-event-title
6 * Optional: data-thumbnail, data-age, data-end-time
7 *
8 * Configure via window.jnpSheetConfig before this script loads:
9 *   window.jnpSheetConfig = { checkout_url, api_tickets, api_session, site_url, success_base }
10 */
11(function(){
12
13// Config injected by wp_localize_script or jnpSheetConfig
14var cfg         = window.jnpSheetConfig || {};
15var EVENTS      = [];  // not used in standalone mode
16var REF         = '';
17var REF_SLUG    = '';
18var CHECKOUT    = cfg.checkout_url || 'https://www.junipersardinia.com/checkout/';
19var API_TICKETS = cfg.api_tickets  || 'https://www.junipersardinia.com/wp-json/jnp-tickets/v1/list';
20var API_SESSION = cfg.api_session  || 'https://www.junipersardinia.com/wp-json/jnp-stripe/v1/create-session';
21var API_BASE    = cfg.api_base     || 'https://www.junipersardinia.com/wp-json';
22var SITE_URL    = cfg.site_url     || 'https://www.junipersardinia.com';
23var PR_BASE     = cfg.success_base || ''; // Empty on consumer site — server uses thank-you page
24var otpState    = { hasPass: false, verified: false, discount: 0, passType: '', passCode: '', sending: false };
25
26/* ── Attribution cookie ─── */
27if (REF) {
28	var exp = new Date();
29	exp.setDate(exp.getDate() + 30); // 30 days
30	document.cookie = 'jnp_ref=' + encodeURIComponent(REF)
31		+ '; expires=' + exp.toUTCString()
32		+ '; path=/; SameSite=Lax'
33		+ (location.protocol === 'https:' ? '; Secure' : '');
34}
35
36/* ── Check for ?order_id= param on page load (Stripe return on main site) ─── */
37(function() {
38	var params = new URLSearchParams(location.search);
39	var orderId = params.get('order_id');
40	if (orderId && params.get('jnp_confirm') === '1') {
41		msShowConfirmation(orderId);
42	}
43})();
44
45/* ── OTP pass verification ──────────────────────────────────────────────── */
46function msOtpSetCtaLocked(locked) {
47	// kept as no-op for legacy call sites — use msCheckFormReady instead
48}
49
50function msCheckFormReady() {
51	var firstName = (document.getElementById('msFirstName') || {}).value || '';
52	var lastName  = (document.getElementById('msLastName')  || {}).value || '';
53	var email     = (document.getElementById('msEmail')     || {}).value || '';
54	var btn       = document.getElementById('msFormSubmitBtn');
55	if (!btn) return;
56
57	var emailTrim = email.trim();
58	var emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(emailTrim);
59	var fieldsOk = firstName.trim() && lastName.trim() && emailValid;
60
61	// Show/hide email format hint
62	var emailEl = document.getElementById('msEmail');
63	var hint = document.getElementById('msEmailHint');
64	if (hint) {
65		hint.style.display = (emailTrim && !emailValid) ? '' : 'none';
66	}
67
68	// DOB validation — only when the field is visible
69	var dobGroup = document.getElementById('msDobGroup');
70	var dobOk = true;
71	if (dobGroup && dobGroup.style.display !== 'none') {
72		var dobVal  = (document.getElementById('msDob') || {}).value || '';
73		var dobHint = document.getElementById('msDobHint');
74		var ageLimit = activeEv ? (activeEv._age_limit || 0) : 0;
75		if (!dobVal) {
76			dobOk = false;
77			if (dobHint) { dobHint.textContent = ''; dobHint.style.display = 'none'; }
78		} else {
79			var dob = new Date(dobVal);
80			var today = new Date();
81			var age = today.getFullYear() - dob.getFullYear();
82			var m = today.getMonth() - dob.getMonth();
83			if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) age--;
84			if (dob >= today || age > 120) {
85				dobOk = false;
86				if (dobHint) { dobHint.textContent = 'Data non valida.'; dobHint.style.display = ''; dobHint.style.color = 'var(--text-danger, #ef4444)'; }
87			} else if (ageLimit && age < ageLimit) {
88				dobOk = false;
89				if (dobHint) { dobHint.textContent = 'Devi avere almeno ' + ageLimit + ' anni per acquistare questo biglietto.'; dobHint.style.display = ''; dobHint.style.color = 'var(--text-danger, #ef4444)'; }
90			} else {
91				dobOk = true;
92				if (dobHint) { dobHint.textContent = ''; dobHint.style.display = 'none'; }
93			}
94		}
95	}
96
97	if (!fieldsOk || !dobOk) { btn.disabled = true; return; }
98
99	// OTP check in progress — keep disabled
100	if (otpState.sending) { btn.disabled = true; return; }
101
102	// Pass found but not yet verified — keep disabled
103	if (otpState.hasPass && !otpState.verified) { btn.disabled = true; return; }
104
105	// Email changed since last check — keep disabled until new check completes
106	if (emailValid && otpState._checkedEmail !== emailTrim) { btn.disabled = true; return; }
107
108	// Valid email but no check started yet
109	if (emailValid && !otpState._checkedEmail) { btn.disabled = true; return; }
110
111	// All good — enable
112	btn.disabled = false;
113}
114
115function msOtpReset() {
116	var prevEmail = otpState._checkedEmail || '';
117	otpState = { hasPass: false, verified: false, discount: 0, passType: '', passCode: '', sending: false, _checkedEmail: prevEmail };
118	// Note: do NOT unlock CTA here — caller decides whether to lock or unlock
119	var block = document.getElementById('msOtpBlock');
120	if (block) block.style.display = 'none';
121	var disc = document.getElementById('msOtpDiscountLine');
122	if (disc) { disc.style.display = 'none'; disc.innerHTML = ''; }
123	var inp = document.getElementById('msOtpInput');
124	if (inp) inp.value = '';
125	msUpdateFormTotal();
126}
127
128function msOtpCheckEmail(email) {
129	if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) return;
130	// Already verified — nothing to do
131	if (otpState.verified) return;
132	// Already sending or already checked this exact email — nothing to do
133	if (otpState.sending || otpState._checkedEmail === email) return;
134	// Set sending flag BEFORE msOtpReset so concurrent calls hit the guard above
135	otpState.sending = true;
136	otpState._checkedEmail = email;
137	msOtpReset(); // clears UI, preserves _checkedEmail and sending via reset
138	otpState.sending = true;
139	otpState._checkedEmail = email;
140	var spinner = document.getElementById('msOtpSpinner');
141	if (spinner) spinner.style.display = '';
142	var block = document.getElementById('msOtpBlock');
143	var notice = document.getElementById('msOtpBanner');
144	fetch(API_BASE + '/jnp-tickets/v1/pass-otp/send', {
145		method: 'POST',
146		headers: { 'Content-Type': 'application/json' },
147		body: JSON.stringify({ email: email, event_id: activeEv ? activeEv.event_id : 0, occurrence_ts: activeEv ? activeEv.ts : 0 }),
148	})
149	.then(function(r) { return r.json(); })
150	.then(function(data) {
151		otpState.sending = false;
152		var spinner = document.getElementById('msOtpSpinner');
153		if (spinner) spinner.style.display = 'none';
154		msCheckFormReady();
155		if (data.has_pass && data.discount > 0) {
156			otpState.hasPass = true;
157			otpState.discount = data.discount;
158			otpState.passType = data.pass_type;
159			if (notice) notice.innerHTML = '🎟 Hai un Season Pass attivo! Abbiamo inviato un codice a <strong>' + esc(email) + '</strong>. Inseriscilo qui sotto per applicare lo sconto di <strong>' + fmt(data.discount) + '</strong> a biglietto.';
160			if (block) block.style.display = '';
161			// Show the input row — CTA stays locked until OTP verified
162			var row = document.getElementById('msOtpInputRow');
163			if (row) row.style.display = '';
164			var inp = document.getElementById('msOtpInput');
165			if (inp) { inp.disabled = false; inp.value = ''; inp.placeholder = '000000'; inp.focus(); }
166		} else {
167			// No pass — unlock CTA immediately
168			msOtpSetCtaLocked(false);
169		}
170	})
171	.catch(function() {
172		otpState.sending = false;
173		var spinner = document.getElementById('msOtpSpinner');
174		if (spinner) spinner.style.display = 'none';
175		msCheckFormReady();
176	});
177}
178
179function msOtpVerify(otp) {
180	var email = document.getElementById('msEmail') ? document.getElementById('msEmail').value.trim() : '';
181	if (!email || !otp) return;
182	var inp = document.getElementById('msOtpInput');
183	if (inp) inp.disabled = true;
184	fetch(API_BASE + '/jnp-tickets/v1/pass-otp/verify', {
185		method: 'POST',
186		headers: { 'Content-Type': 'application/json' },
187		body: JSON.stringify({ email: email, otp: otp }),
188	})
189	.then(function(r) { return r.json(); })
190	.then(function(data) {
191		if (data.valid) {
192			otpState.verified = true;
193			otpState.passCode = data.pass_code;
194			otpState.passType = data.pass_type;
195			otpState.discount = data.discount;
196			// Hide OTP input row, show discount line
197			var row = document.getElementById('msOtpInputRow');
198			if (row) row.style.display = 'none';
199			var disc = document.getElementById('msOtpDiscountLine');
200			if (disc) {
201				disc.innerHTML = '✅ Sconto Season Pass applicato: <strong>−' + fmt(data.discount) + '</strong> a biglietto';
202				disc.style.display = '';
203			}
204			msCheckFormReady(); // OTP verified — re-evaluate button state
205			msUpdateFormTotal();
206		} else {
207			if (inp) { inp.disabled = false; inp.value = ''; inp.placeholder = data.message || 'Codice non valido. Riprova.'; }
208		}
209	})
210	.catch(function() { if (inp) { inp.disabled = false; } });
211}
212
213window.msOtpResend = function(e) {
214	e.preventDefault();
215	var email = document.getElementById('msEmail') ? document.getElementById('msEmail').value.trim() : '';
216	otpState.sending = false;
217	msOtpReset();
218	msOtpCheckEmail(email);
219};
220
221function msUpdateFormTotal() {
222	if (!activeEv) return;
223	var tickets = activeEv._jnp_tickets || [];
224	var total = 0;
225	var discountApplied = false; // track whether the 1 discounted ticket has been counted
226	tickets.forEach(function(t) {
227		var q = ticketQty[t.id] || 0;
228		var price = parseFloat(t.price) || 0;
229		if (otpState.verified && otpState.discount > 0 && q > 0 && !discountApplied) {
230			// Apply discount to exactly 1 ticket across all ticket types
231			total += Math.max(0, price - otpState.discount); // 1 discounted
232			total += price * (q - 1); // rest at full price
233			discountApplied = true;
234		} else {
235			total += price * q;
236		}
237	});
238	// Update summary in form
239	var summary = document.getElementById('msFormSummary');
240	if (summary && activeEv) {
241		var lines = '';
242		var summaryDiscountApplied = false;
243		tickets.forEach(function(t) {
244			var q = ticketQty[t.id] || 0;
245			if (q > 0) {
246				var origPrice = parseFloat(t.price) || 0;
247				// Discount applies to first ticket of the first type only
248				var sub;
249				var thisRowDiscounted = false;
250				if (otpState.verified && otpState.discount > 0 && !summaryDiscountApplied) {
251					sub = Math.max(0, origPrice - otpState.discount) + origPrice * (q - 1);
252					summaryDiscountApplied = true;
253					thisRowDiscounted = true;
254				} else {
255					sub = origPrice * q;
256				}
257				lines += '<div class="ms-form-summary-row"><span>' + esc(t.name) + ' × ' + q + '</span>'
258					+ '<span>' + (thisRowDiscounted ? '<s style="color:#71717a;font-size:12px;">' + fmt(q * origPrice) + '</s> ' : '')
259					+ fmt(sub) + '</span></div>';
260			}
261		});
262		if (otpState.verified && otpState.discount > 0) {
263			lines += '<div class="ms-form-summary-row" style="color:#22c55e;font-size:12px;"><span>Sconto Season Pass (1 biglietto)</span><span>−' + fmt(otpState.discount) + '</span></div>';
264		}
265		lines += '<div class="ms-form-summary-row ms-form-summary-total"><span>Totale</span><span>' + fmt(total) + '</span></div>';
266		summary.innerHTML = lines;
267	}
268}
269
270
271var detailOverlay = document.getElementById('msDetailOverlay');
272
273var DAY = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
274var MON = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
275
276/* ── Show confirmation screen (called on ?order= param) ─── */
277function msShowConfirmation(orderId) {
278	// Fetch order summary from API
279	fetch(SITE_URL + '/wp-json/jnp-sales/v1/list?order_id=' + orderId)
280		.then(function(r) { return r.ok ? r.json() : null; })
281		.then(function(data) {
282			msRenderConfirmation(orderId, data);
283		})
284		.catch(function() {
285			msRenderConfirmation(orderId, null);
286		});
287}
288
289function msRenderConfirmation(orderId, data) {
290	var overlay = document.getElementById('msDetailOverlay');
291	var sheet   = overlay ? overlay.querySelector('.ms-sheet') : null;
292	if (!sheet) return;
293
294	// Replace sheet content with confirmation
295	sheet.innerHTML =
296		'<div class="ms-confirm-screen">' +
297		'<div class="ms-confirm-icon">🎟</div>' +
298		'<h2 class="ms-confirm-title">Acquisto completato!</h2>' +
299		'<p class="ms-confirm-sub">I tuoi biglietti sono stati inviati via email.<br>Trovi i biglietti anche nella tua area personale su Juniper.</p>' +
300		'<div class="ms-confirm-summary" id="msConfirmSummary">' +
301		'<div class="ms-confirm-row"><span>Ordine</span><span>#' + orderId + '</span></div>' +
302		'</div>' +
303		'<button class="ms-sheet-cta-btn" style="width:100%;justify-content:center;gap:10px;" onclick="msCloseDetail()">' +
304		'Chiudi' +
305		'</button>' +
306		'</div>';
307
308	overlay.classList.add('open');
309	document.body.style.overflow = 'hidden';
310
311	// Clean the URL so refreshing doesn't re-show confirmation
312	if (history.replaceState) {
313		history.replaceState({}, '', location.pathname);
314	}
315}
316
317/* ── Open detail sheet ─── */
318window.msOpenDetail = function(idx) {
319	var ev = EVENTS[idx]; if (!ev) return;
320	activeEv  = ev;
321	ticketQty = {};
322	sheetStep = 'tickets';
323
324	var bg  = document.getElementById('msSheetHeroBg');
325	var img = document.getElementById('msSheetHeroImg');
326	var ph  = document.getElementById('msSheetHeroPh');
327	if (ev.thumbnail) {
328		bg.style.backgroundImage  = "url('" + ev.thumbnail + "')";
329		img.style.backgroundImage = "url('" + ev.thumbnail + "')";
330		ph.style.display = 'none';
331	} else {
332		bg.style.backgroundImage = img.style.backgroundImage = '';
333		ph.style.display = 'block';
334	}
335
336	var d = new Date(ev.ts * 1000);
337	var endTime = ev.end_time ? ev.end_time.toUpperCase() : 'TILL LATE';
338	document.getElementById('msSheetDate').innerHTML =
339		'<span class="ms-date-badge">'
340		+ '<span class="ms-date-badge-day">' + esc(DAY[d.getDay()]) + ' </span>'
341		+ '<span class="ms-date-badge-num">' + pad(d.getDate()) + '</span>'
342		+ '<span class="ms-date-badge-mon"> ' + MON[d.getMonth()] + '</span>'
343		+ '</span>'
344		+ '<span class="ms-card-time">'
345		+ esc(pad(d.getHours()) + ':' + pad(d.getMinutes()) + ' → ' + endTime)
346		+ '</span>';
347	document.getElementById('msSheetTitle').textContent = ev.label || ev.event_title;
348
349	msShowTicketsStep();
350
351	var tSec  = document.getElementById('msSheetTickets');
352	var tRows = document.getElementById('msSheetTicketRows');
353	tRows.innerHTML = '<p style="color:#52525b;font-size:13px;padding:12px 0;">Caricamento…</p>';
354	tSec.style.display = '';
355	document.getElementById('msSheetTables').style.display   = 'none';
356	document.getElementById('msSheetNoTickets').style.display = 'none';
357	document.getElementById('msSheetCta').style.display       = 'none';
358	detailOverlay.classList.add('open');
359	document.body.style.overflow = 'hidden';
360
361	fetch(API_TICKETS + '?event_id=' + ev.event_id + '&occurrence_ts=' + ev.ts)
362		.then(function(r) { return r.ok ? r.json() : {}; })
363		.then(function(resp) {
364			// Response is now { tickets, event_age_limit, user_dob }
365			// Fallback to plain array for backwards compatibility
366			var tickets = Array.isArray(resp) ? resp : (resp.tickets || []);
367			ev._jnp_tickets    = tickets;
368			ev._age_limit      = Array.isArray(resp) ? (ev.age ? parseInt(ev.age, 10) : null) : (resp.event_age_limit || null);
369			ev._user_dob       = Array.isArray(resp) ? '' : (resp.user_dob || '');
370			msRenderTicketRows(ev);
371		})
372		.catch(function() { ev._jnp_tickets = []; msRenderTicketRows(ev); });
373
374	var tabSec  = document.getElementById('msSheetTables');
375	var tabRows = document.getElementById('msSheetTableRows');
376	if (ev.tables && ev.tables.length) {
377		tabRows.innerHTML = ev.tables.map(function(t) {
378			var out = t.sold_out || t.available === 0;
379			return '<div class="ms-ticket-row' + (out ? ' unavail' : '') + '">'
380				+ '<div class="ms-ticket-info"><div class="ms-ticket-info-name">' + esc(t.name) + '</div>'
381				+ (t.price ? '<div class="ms-ticket-info-price">Da ' + fmt(t.price) + '</div>' : '') + '</div>'
382				+ (out ? '<span class="ms-badge-sold">Completo</span>' : '<span class="ms-item-arrow">›</span>')
383				+ '</div>';
384		}).join('');
385		tabSec.style.display = '';
386	} else { tabSec.style.display = 'none'; }
387};
388
389function msShowTicketsStep() {
390	sheetStep = 'tickets';
391	document.getElementById('msSheetDate').style.display    = '';
392	document.getElementById('msSheetTitle').style.display   = '';
393	document.getElementById('msSheetInfo') && (document.getElementById('msSheetInfo').style.display = '');
394	document.getElementById('msSheetTickets').style.display  = '';
395	document.getElementById('msFormStep').style.display      = 'none';
396	// CTA state restored by msUpdateCta
397}
398
399function msShowFormStep() {
400	// Button starts disabled — enabled only when all fields filled + OTP resolved
401	var btn = document.getElementById('msFormSubmitBtn');
402	if (btn) btn.disabled = true;
403
404	sheetStep = 'form';
405	// Hide ticket selection, show form
406	document.getElementById('msSheetTickets').style.display  = 'none';
407	document.getElementById('msSheetTables') && (document.getElementById('msSheetTables').style.display = 'none');
408	document.getElementById('msSheetNoTickets').style.display = 'none';
409	document.getElementById('msSheetDate').style.display     = 'none';
410	document.getElementById('msSheetTitle').style.display    = 'none';
411	document.getElementById('msSheetInfo') && (document.getElementById('msSheetInfo').style.display = 'none');
412	document.getElementById('msFormStep').style.display      = '';
413
414	// Populate order summary in form
415	var tickets  = activeEv ? (activeEv._jnp_tickets || []) : [];
416	var total    = 0;
417	var lines    = '';
418	tickets.forEach(function(t) {
419		var q = ticketQty[t.id] || 0;
420		if (q > 0) {
421			var sub = q * (parseFloat(t.price) || 0);
422			total  += sub;
423			lines  += '<div class="ms-form-summary-row"><span>' + esc(t.name) + ' × ' + q + '</span><span>' + fmt(sub) + '</span></div>';
424		}
425	});
426	lines += '<div class="ms-form-summary-row ms-form-summary-total"><span>Totale</span><span>' + fmt(total) + '</span></div>';
427	document.getElementById('msFormSummary').innerHTML = lines;
428
429	// Hide sticky CTA — form has its own submit button
430	document.getElementById('msSheetCta').style.display = 'none';
431
432	// Bind field input listeners — every change re-evaluates button state
433	['msFirstName','msLastName','msEmail'].forEach(function(id) {
434		var el = document.getElementById(id);
435		if (el && !el._readyBound) {
436			el._readyBound = true;
437			// Remove readonly on focus to allow typing (suppresses iOS autofill scanning)
438			el.addEventListener('focus', function() { this.removeAttribute('readonly'); });
439			el.addEventListener('touchstart', function() { this.removeAttribute('readonly'); });
440			['input','change','blur','keyup'].forEach(function(ev) {
441				el.addEventListener(ev, msCheckFormReady);
442			});
443		}
444	});
445
446	// DOB field — show only when event has a minimum age requirement
447	var dobGroup = document.getElementById('msDobGroup');
448	var dobInput = document.getElementById('msDob');
449	var ageLimit = activeEv ? (activeEv._age_limit || 0) : 0;
450	if (dobGroup && ageLimit > 0) {
451		dobGroup.style.display = '';
452		// Set max date to today (can't be born in the future)
453		var todayStr = new Date().toISOString().split('T')[0];
454		if (dobInput) dobInput.max = todayStr;
455		// Update the "(minimum 18 anni)" label
456		var ageReqSpan = document.getElementById('msDobAgeReq');
457		if (ageReqSpan) ageReqSpan.textContent = '(minimo ' + ageLimit + ' anni)';
458		// Pre-fill from user meta if the server returned a stored DOB
459		var userDob = activeEv._user_dob || '';
460		if (dobInput && userDob && !dobInput.value) {
461			dobInput.value = userDob;
462		}
463		// Bind change listener
464		if (dobInput && !dobInput._readyBound) {
465			dobInput._readyBound = true;
466			['input','change','blur'].forEach(function(ev) {
467				dobInput.addEventListener(ev, msCheckFormReady);
468			});
469		}
470	} else if (dobGroup) {
471		dobGroup.style.display = 'none';
472		if (dobInput) dobInput.value = '';
473	}
474
475	// OTP check on email — debounced so rapid events (input+change+blur) collapse into one call
476	var emailEl = document.getElementById('msEmail');
477	if (emailEl && !emailEl._otpBound) {
478		emailEl._otpBound = true;
479		var _otpDebounceTimer = null;
480		var _otpEmailHandler = function() {
481			var val = this.value.trim();
482			msCheckFormReady();
483			clearTimeout(_otpDebounceTimer);
484			_otpDebounceTimer = setTimeout(function() {
485				if (/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(val)) {
486					msOtpCheckEmail(val);
487				}
488			}, 300);
489		};
490		['blur','change','input'].forEach(function(ev) {
491			emailEl.addEventListener(ev, _otpEmailHandler);
492		});
493	}
494
495	// Scroll sheet to top
496	var sheet = detailOverlay.querySelector('.ms-sheet');
497	if (sheet) sheet.scrollTop = 0;
498}
499
500/* Render ticket rows with inline steppers */
501function msRenderTicketRows(ev) {
502	var tSec   = document.getElementById('msSheetTickets');
503	var tRows  = document.getElementById('msSheetTicketRows');
504	var noTick = document.getElementById('msSheetNoTickets');
505	var tickets = ev._jnp_tickets || [];
506
507	if (tickets.length) {
508		tRows.innerHTML = tickets.map(function(t) {
509			var out = t.sold_out || t.available === 0 || t.locked;
510			var priceLabel = t.price ? fmt(t.price) : 'Gratuito';
511			// max = available (if limited) or 10 (if unlimited, available === null),
512			// further capped by the ticket's max_per_order setting (if set)
513			var maxQty = (t.available !== null && t.available !== undefined) ? t.available : 10;
514			if (t.max_per_order !== null && t.max_per_order !== undefined) {
515				maxQty = Math.min(maxQty, t.max_per_order);
516			}
517			// Build combo badge and leg summary if applicable
518			var isCombo = t.combo_legs && t.combo_legs.length > 0;
519			var comboBadge = isCombo ? '<span class="ms-badge-combo">COMBO</span>' : '';
520			var comboLegs = '';
521			if (isCombo) {
522				var legParts = t.combo_legs.map(function(leg) {
523					var d = new Date(leg.occurrence_ts * 1000);
524					var day = ('0' + d.getDate()).slice(-2);
525					var months = ['Gen','Feb','Mar','Apr','Mag','Giu','Lug','Ago','Set','Ott','Nov','Dic'];
526					return leg.qty + '× ' + esc(leg.title) + ' — ' + day + ' ' + months[d.getMonth()];
527				});
528				comboLegs = '<div class="ms-combo-legs">🎟 ' + legParts.join(' &nbsp;·&nbsp; ') + '</div>';
529			}
530
531			if (out) {
532				var badgeLabel = t.locked ? 'Prossimamente' : (t.outside_window ? 'Non disponibile' : 'Esaurito');
533				return '<div class="ms-ticket-row unavail">'
534					+ '<div class="ms-ticket-info"><div class="ms-ticket-info-name">' + comboBadge + esc(t.name) + '</div>'
535					+ '<div class="ms-ticket-info-price">' + priceLabel + '</div>'
536					+ comboLegs + '</div>'
537					+ (t.description ? '<button class="ms-info-btn" data-info-name="' + esc(t.name) + '" data-info-desc="' + esc(t.description) + '" aria-label="Info"><span>i</span></button>' : '')
538				+ '<span class="ms-badge-sold">' + badgeLabel + '</span></div>';
539			}
540			// Store maxQty as data attribute so msTicketStep can read it
541			return '<div class="ms-ticket-row" id="ms-trow-' + t.id + '" data-max="' + maxQty + '">'
542				+ '<div class="ms-ticket-info"><div class="ms-ticket-info-name">' + comboBadge + esc(t.name) + '</div>'
543				+ '<div class="ms-ticket-info-price">' + priceLabel + '</div>'
544				+ comboLegs + '</div>'
545				+ '<div class="ms-ticket-stepper">'
546				+ (t.description ? '<button class="ms-info-btn" data-info-name="' + esc(t.name) + '" data-info-desc="' + esc(t.description) + '" aria-label="Info"><span>i</span></button>' : '')
547				+ '<button class="ms-inline-btn" onclick="msTicketStep(' + t.id + ',-1,event)">−</button>'
548				+ '<span class="ms-inline-qty" id="ms-qty-' + t.id + '">0</span>'
549				+ '<button class="ms-inline-btn ms-inline-plus" onclick="msTicketStep(' + t.id + ',1,event)">+</button>'
550				+ '</div></div>';
551		}).join('');
552		tSec.style.display = '';
553		noTick.style.display = 'none';
554	} else {
555		tRows.innerHTML = '';
556		tSec.style.display = 'none';
557		noTick.style.display = (ev.tables && ev.tables.length) ? 'none' : '';
558	}
559}
560
561/* ── Per-ticket stepper ─── */
562window.msTicketStep = function(ticketId, delta, e) {
563	e && e.stopPropagation();
564	// Read per-ticket max from the row's data-max attribute
565	var row    = document.getElementById('ms-trow-' + ticketId);
566	var maxQty = row ? parseInt(row.getAttribute('data-max'), 10) : 10;
567	if (isNaN(maxQty)) maxQty = 10;
568	var current = ticketQty[ticketId] || 0;
569	var next    = Math.max(0, Math.min(maxQty, current + delta));
570	ticketQty[ticketId] = next;
571	var qtyEl = document.getElementById('ms-qty-' + ticketId);
572	if (qtyEl) qtyEl.textContent = next;
573	msUpdateCta();
574};
575
576function msUpdateCta() {
577	if (!activeEv) return;
578	var tickets = activeEv._jnp_tickets || [];
579	var total = 0, count = 0;
580	tickets.forEach(function(t) {
581		var q = ticketQty[t.id] || 0;
582		total += q * (parseFloat(t.price) || 0);
583		count += q;
584	});
585	var cta    = document.getElementById('msSheetCta');
586	var ctaBtn = document.getElementById('msSheetCtaBtn');
587	var ctaTot = document.getElementById('msSheetCtaTotal');
588	var ctaQty = document.getElementById('msSheetCtaQty');
589	if (count > 0 && sheetStep === 'tickets') {
590		cta.style.display = '';
591		if (ctaTot) ctaTot.textContent = fmt(total);
592		if (ctaQty) ctaQty.textContent = count;
593	} else {
594		cta.style.display = 'none';
595	}
596}
597
598window.msBackToTickets = function() {
599	msShowTicketsStep();
600	msUpdateCta();
601};
602
603window.msCloseDetail = function(force) {
604	// If we're on the form step, ask for confirmation before closing
605	if (!force && sheetStep === 'form') {
606		if (!confirm('Sei sicuro di voler uscire? Perderai i dati inseriti.')) return;
607	}
608	detailOverlay.classList.remove('open');
609	document.body.style.overflow = '';
610	sheetStep = 'tickets';
611};
612detailOverlay && detailOverlay.addEventListener('click', function(e) {
613	// Backdrop click disabled — use X button to close
614});
615
616/* ── Submit — step 1: go to form; step 2: call API ─── */
617window.msSubmit = function() {
618	if (sheetStep === 'tickets') {
619		msShowFormStep();
620		return;
621	}
622	// Step 2: validate form and call Stripe session API
623	var firstName = document.getElementById('msFirstName').value.trim();
624	var lastName  = document.getElementById('msLastName').value.trim();
625	var email     = document.getElementById('msEmail').value.trim();
626	var errEl     = document.getElementById('msFormError');
627
628	if (!firstName || !lastName || !email || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
629		errEl.classList.add('visible');
630		return;
631	}
632	errEl.classList.remove('visible');
633
634
635
636	var tickets  = activeEv ? (activeEv._jnp_tickets || []) : [];
637	var selected = {};
638	var hasAny   = false;
639	tickets.forEach(function(t) {
640		var q = ticketQty[t.id] || 0;
641		if (q > 0) { selected[t.id] = q; hasAny = true; }
642	});
643	if (!hasAny) return;
644
645	var formBtn = document.getElementById('msFormSubmitBtn');
646	if (formBtn) { formBtn.disabled = true; formBtn.textContent = 'Elaborazione…'; }
647
648	var dobVal = '';
649	var dobInput = document.getElementById('msDob');
650	var dobGroup = document.getElementById('msDobGroup');
651	if (dobInput && dobGroup && dobGroup.style.display !== 'none') {
652		dobVal = dobInput.value.trim();
653	}
654
655	// Success URL points back to microsite with order param
656	var payload = {
657		event_id:         activeEv.event_id,
658		occurrence_ts:    activeEv.ts,
659		selected_tickets: selected,
660		first_name:       firstName,
661		last_name:        lastName,
662		email:            email,
663		ref:              REF,
664		referer:          location.href,
665		cancel_url:       location.href,
666	};
667	if (dobVal) payload.dob = dobVal;
668	// Include OTP-verified pass discount if present
669	if (otpState.verified && otpState.passCode && otpState.discount > 0) {
670		payload.pass_code     = otpState.passCode;
671		payload.otp_discount  = otpState.discount;
672		payload.otp_pass_type = otpState.passType;
673	}
674	// Only set success_url_base if we have a PR microsite base (e.g. pr.junipersardinia.com/giulia)
675	// Otherwise leave unset so the server uses the default thank-you page
676	if (PR_BASE) { payload.success_url_base = PR_BASE; }
677
678	fetch(API_SESSION, {
679		method:  'POST',
680		headers: { 'Content-Type': 'application/json' },
681		body:    JSON.stringify(payload),
682	})
683	.then(function(r) { return r.json(); })
684	.then(function(data) {
685		if (data.url) {
686			window.location.href = data.url;
687		} else {
688			var msg = data.message || 'Si è verificato un errore. Riprova.';
689			var formBtn = document.getElementById('msFormSubmitBtn');
690			if (formBtn) { formBtn.disabled = false; formBtn.textContent = 'Procedi al pagamento →'; }
691			document.getElementById('msFormError').textContent = msg;
692			document.getElementById('msFormError').classList.add('visible');
693		}
694	})
695	.catch(function() {
696		var formBtn = document.getElementById('msFormSubmitBtn');
697		if (formBtn) { formBtn.disabled = false; formBtn.textContent = 'Procedi al pagamento →'; }
698		document.getElementById('msFormError').textContent = 'Errore di connessione. Riprova.';
699		document.getElementById('msFormError').classList.add('visible');
700	});
701};
702
703/* ── Keyboard ─── */
704document.addEventListener('keydown', function(e) {
705	if (e.key === 'Escape' && detailOverlay && detailOverlay.classList.contains('open')) {
706		msCloseDetail();
707	}
708});
709
710/* ── Helpers ─── */
711function pad(n) { return n < 10 ? '0' + n : '' + n; }
712function fmt(v) { return parseFloat(v).toLocaleString('it-IT', {minimumFractionDigits:2, maximumFractionDigits:2}) + ' €'; }
713function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
714
715/* ── Ticket info popup ─────────────────────────────────────────────── */
716window.msShowInfo = function msShowInfo(name, description, evt) {
717	if (evt) { evt.preventDefault(); evt.stopPropagation(); }
718	var existing = document.getElementById('msInfoPopup');
719	if (existing) existing.remove();
720	var popup = document.createElement('div');
721	popup.id = 'msInfoPopup';
722	popup.innerHTML = '<div class="ms-info-popup-backdrop" onclick="document.getElementById(\'msInfoPopup\').remove()"></div>'
723		+ '<div class="ms-info-popup-box">'
724		+ '<h4 class="ms-info-popup-title">' + esc(name) + '</h4>'
725		+ '<div class="ms-info-popup-body">' + esc(description).replace(/\n/g,'<br>') + '</div>'
726		+ '<button class="ms-info-popup-close" onclick="document.getElementById(\'msInfoPopup\').remove()">Chiudi</button>'
727		+ '</div>';
728	document.body.appendChild(popup);
729}
730
731
732/* ── Public API ─────────────────────────────────────────────────────── */
733window.jnpOpenTicketSheet = function(eventId, occurrenceTs, eventTitle, thumbnail, age, endTime) {
734	// Close any open Bootstrap modal first
735	if (typeof jQuery !== 'undefined') {
736		jQuery('.modal.in').modal('hide');
737	}
738
739	activeEv = {
740		event_id:    eventId,
741		ts:          occurrenceTs,
742		event_title: eventTitle || '',
743		label:       eventTitle || '',
744		thumbnail:   thumbnail  || '',
745		age:         age        || '',
746		end_time:    endTime    || '',
747		_jnp_tickets: null,
748	};
749	ticketQty = {};
750	sheetStep = 'tickets';
751	otpState  = { hasPass: false, verified: false, discount: 0, passType: '', passCode: '', sending: false, _checkedEmail: '' };
752
753	var overlay = document.getElementById('msDetailOverlay');
754	if (!overlay) { buildSheetDOM(); overlay = document.getElementById('msDetailOverlay'); }
755
756	// Set hero
757	var bg  = document.getElementById('msSheetHeroBg');
758	var img = document.getElementById('msSheetHeroImg');
759	var ph  = document.getElementById('msSheetHeroPh');
760	if (thumbnail) {
761		bg.style.backgroundImage = img.style.backgroundImage = "url('" + thumbnail + "')";
762		ph.style.display = 'none';
763	} else {
764		bg.style.backgroundImage = img.style.backgroundImage = '';
765		ph.style.display = 'block';
766	}
767
768	// Set title/date
769	var d = new Date(occurrenceTs * 1000);
770	var timeLabel = pad(d.getHours()) + ':' + pad(d.getMinutes()) + (endTime ? ' → ' + endTime : '');
771	document.getElementById('msSheetDate').innerHTML =
772		'<span class="ms-date-badge"><span class="ms-date-badge-day">' + DAY[d.getDay()] + ' </span>'
773		+ '<span class="ms-date-badge-num">' + pad(d.getDate()) + '</span>'
774		+ '<span class="ms-date-badge-mon"> ' + MON[d.getMonth()] + '</span></span>'
775		+ '<span class="ms-card-time">' + esc(timeLabel) + '</span>'
776		+ (age ? '<span class="ms-card-age" style="margin-left:6px;padding:1px 7px;border-radius:10px;background:rgba(0,0,0,0.08);font-size:11px;font-weight:600;letter-spacing:.02em;">' + esc(age) + '+</span>' : '');
777	document.getElementById('msSheetTitle').textContent = eventTitle || '';
778
779	msShowTicketsStep();
780	document.getElementById('msSheetTicketRows').innerHTML = '<p style="color:#52525b;font-size:13px;padding:12px 0;">Caricamento…</p>';
781	document.getElementById('msSheetTickets').style.display = '';
782	document.getElementById('msSheetTables').style.display  = 'none';
783	document.getElementById('msSheetNoTickets').style.display = 'none';
784	document.getElementById('msSheetCta').style.display = 'none';
785	overlay.classList.add('open');
786	document.body.style.overflow = 'hidden';
787
788	fetch(API_TICKETS + '?event_id=' + eventId + '&occurrence_ts=' + occurrenceTs)
789		.then(function(r) { return r.ok ? r.json() : {}; })
790		.then(function(resp) {
791			var tickets = Array.isArray(resp) ? resp : (resp.tickets || []);
792			activeEv._jnp_tickets = tickets;
793			activeEv._age_limit   = Array.isArray(resp) ? (age ? parseInt(age, 10) : null) : (resp.event_age_limit || null);
794			activeEv._user_dob    = Array.isArray(resp) ? '' : (resp.user_dob || '');
795			msRenderTicketRows(activeEv);
796		})
797		.catch(function() { activeEv._jnp_tickets = []; msRenderTicketRows(activeEv); });
798};
799
800function buildSheetDOM() {
801	if (document.getElementById('msDetailOverlay')) return;
802	var el = document.createElement('div');
803	el.id = 'msDetailOverlay';
804	el.className = 'ms-overlay';
805	el.innerHTML = [
806		'<div class="ms-sheet">',
807		'<div class="ms-sheet-handle"></div>',
808		'<div class="ms-sheet-hero" id="msSheetHero">',
809		'  <div class="ms-sheet-hero-ph" id="msSheetHeroPh"></div>',
810		'  <div class="ms-sheet-hero-bg"  id="msSheetHeroBg"></div>',
811		'  <div class="ms-sheet-hero-img" id="msSheetHeroImg"></div>',
812		'  <div class="ms-sheet-hero-grad"></div>',
813		'  <button class="ms-sheet-close" onclick="msCloseDetail()" aria-label="Chiudi"><i class="las la-times" aria-hidden="true"></i></button>',
814		'</div>',
815		'<div class="ms-sheet-body">',
816		'  <div class="ms-sheet-date" id="msSheetDate"></div>',
817		'  <h2 class="ms-sheet-title" id="msSheetTitle"></h2>',
818		'  <div class="ms-sheet-info" id="msSheetInfo"></div>',
819		'  <div id="msSheetTickets" style="display:none;">',
820		'    <div class="ms-sec-head"><div class="ms-sec-bar"></div><span class="ms-sec-label">Biglietti</span></div>',
821		'    <div id="msSheetTicketRows"></div>',
822		'  </div>',
823		'  <div id="msSheetTables" style="display:none;">',
824		'    <div class="ms-sec-head"><div class="ms-sec-bar"></div><span class="ms-sec-label">Tavoli</span></div>',
825		'    <div id="msSheetTableRows"></div>',
826		'  </div>',
827		'  <div id="msSheetNoTickets" style="display:none;padding:12px 0 4px;text-align:center;color:#52525b;font-size:13px;">Biglietti in arrivo</div>',
828		'  <div class="ms-sheet-cta" id="msSheetCta" style="display:none;">',
829		'    <button class="ms-sheet-cta-btn" onclick="msSubmit()" id="msSheetCtaBtn">',
830		'      <span class="ms-sheet-cta-left"><span class="ms-sheet-cta-qty" id="msSheetCtaQty">1</span><span class="ms-sheet-cta-label">Vai al pagamento</span></span>',
831		'      <span class="ms-sheet-cta-right"><span class="ms-sheet-cta-total" id="msSheetCtaTotal">€0.00</span><span class="ms-sheet-cta-arrow">→</span></span>',
832		'    </button>',
833		'  </div>',
834		'  <div class="ms-form-step" id="msFormStep" style="display:none;">',
835		'    <button class="ms-back-btn" onclick="msBackToTickets()">← Torna ai biglietti</button>',
836		'    <div class="ms-form-summary" id="msFormSummary"></div>',
837		'    <form autocomplete="off" onsubmit="return false;" style="display:contents;">',
838		'    <div class="ms-form-row">',
839		'      <div class="ms-form-group"><label class="ms-form-label" for="msFirstName">Nome</label><input class="ms-form-input" type="text" id="msFirstName" name="jnp-fname" placeholder="Mario" autocomplete="new-password" autocorrect="off" autocapitalize="off" spellcheck="false" readonly></div>',
840		'      <div class="ms-form-group"><label class="ms-form-label" for="msLastName">Cognome</label><input class="ms-form-input" type="text" id="msLastName" name="jnp-lname" placeholder="Rossi" autocomplete="new-password" autocorrect="off" autocapitalize="off" spellcheck="false" readonly></div>',
841		'    </div>',
842		'    <div class="ms-form-group ms-email-group"><label class="ms-form-label" for="msEmail">Email</label><div class="ms-email-wrap"><input class="ms-form-input" type="text" id="msEmail" name="jnp-email" placeholder="[email protected]" autocomplete="new-password" autocorrect="off" autocapitalize="off" spellcheck="false" inputmode="email" readonly><span class="ms-otp-spinner" id="msOtpSpinner" style="display:none;"></span></div><span class="ms-email-hint" id="msEmailHint" style="display:none;">Inserisci un indirizzo email valido (es. [email protected])</span></div>',
843		'    <div class="ms-form-group" id="msDobGroup" style="display:none;">',
844		'      <label class="ms-form-label" for="msDob">Data di nascita <span id="msDobAgeReq" style="font-weight:400;opacity:.7;"></span></label>',
845		'      <input class="ms-form-input" type="date" id="msDob" name="jnp-dob" autocomplete="bday" max="">',
846		'      <span class="ms-email-hint" id="msDobHint" style="display:none;"></span>',
847		'    </div>',
848		'    <div class="ms-otp-block" id="msOtpBlock" style="display:none;">',
849		'      <div class="ms-otp-banner" id="msOtpBanner"></div>',
850		'      <div class="ms-otp-input-row" id="msOtpInputRow" style="display:none;">',
851		'        <input class="ms-form-input ms-otp-input" type="text" id="msOtpInput" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code">',
852		'        <div class="ms-otp-error" id="msOtpError"></div>',
853		'        <button type="button" class="ms-otp-resend" id="msOtpResend" onclick="msOtpResend()">Non hai ricevuto il codice? Reinvia</button>',
854		'      </div>',
855		'      <div class="ms-otp-discount-line" id="msOtpDiscountLine" style="display:none;"></div>',
856		'    </div>',
857		'    <div class="ms-form-error" id="msFormError">Compila tutti i campi correttamente.</div>',
858		'    </form>',
859		'    <button class="ms-form-submit" id="msFormSubmitBtn" onclick="msSubmit()" disabled>Procedi al pagamento →</button>',
860		'    <p class="ms-form-secure">🔒 Pagamento sicuro via Stripe</p>',
861		'  </div>',
862		'</div>',
863		'</div>',
864	].join('\n');
865	document.body.appendChild(el);
866	el.addEventListener('click', function(e) { if (e.target === el) msCloseDetail(); });
867
868	// Inject combo styles
869	if (!document.getElementById('ms-combo-styles')) {
870		var s = document.createElement('style');
871		s.id = 'ms-combo-styles';
872		s.textContent = [
873			'.ms-badge-combo{display:inline-block;background:#8E0633;color:#fff;font-size:9px;font-weight:700;letter-spacing:.08em;padding:2px 6px;border-radius:4px;margin-right:6px;vertical-align:middle;text-transform:uppercase;}',
874			'.ms-combo-legs{font-size:11px;color:rgba(255,255,255,0.6);margin-top:4px;line-height:1.4;}',
875		].join('');
876		document.head.appendChild(s);
877	}
878
879	// Re-bind overlay reference
880	detailOverlay = el;
881}
882
883// Build DOM and bind buttons — works regardless of readyState
884function jnpInit() {
885	if (!document.getElementById('msDetailOverlay')) buildSheetDOM();
886	document.addEventListener('click', function(e) {
887		// Info button
888		var info = e.target.closest('.ms-info-btn');
889		if (info) {
890			e.preventDefault(); e.stopPropagation();
891			window.msShowInfo(info.dataset.infoName || '', info.dataset.infoDesc || '');
892			return;
893		}
894		// Ticket picker button
895		var btn = e.target.closest('.jnp-ticket-picker-btn');
896		if (!btn) return;
897		e.preventDefault();
898		window.jnpOpenTicketSheet(
899			btn.dataset.eventId,
900			btn.dataset.occurrenceTs,
901			btn.dataset.eventTitle || '',
902			btn.dataset.thumbnail  || '',
903			btn.dataset.age        || '',
904			btn.dataset.endTime    || ''
905		);
906	});
907
908	// OTP input → auto-verify at 6 digits
909	document.addEventListener('input', function(e) {
910		if (e.target && e.target.id === 'msOtpInput') {
911			var val = e.target.value.replace(/[^0-9]/g, '').substring(0, 6);
912			e.target.value = val;
913			if (val.length === 6) msOtpVerify(val);
914		}
915	});
916}
917
918if (document.readyState === 'loading') {
919	document.addEventListener('DOMContentLoaded', jnpInit);
920} else {
921	jnpInit();
922}
923})();

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.