PageSourceSearch

https://bodify-intake.onrender.com/bodify/book-b/app.js

js bodify-intake.onrender.com collected 2026-10-03 11:39:20 UTC 45,291 bytes, 1,279 lines download raw bytes

1(function () {
2  'use strict';
3
4
5  // ===== A/B Variant: Phone→Name (control) vs Name→Phone (test) =====
6  // 50/50 split assigned on first /bodify/book load, persisted in localStorage.
7  // Control → window.__bodifyVersion = 'v1b' (unchanged — preserves dashboard continuity)
8  // Test    → window.__bodifyVersion = 'v1b-name'
9  // QA override: append ?variant=v1b or ?variant=v1b-name to the URL.
10  // If a /bodify/book-vN route already set window.__bodifyVersion server-side, leave it alone.
11  var IS_NAME_FIRST = (function () {
12    if (window.__bodifyVersion) {
13      return window.__bodifyVersion === 'v1b-name';
14    }
15    var urlVariant = null;
16    try {
17      var v = new URLSearchParams(window.location.search).get('variant');
18      if (v === 'v1b' || v === 'v1b-name') urlVariant = v;
19    } catch (e) {}
20
21    var stored = null;
22    try { stored = localStorage.getItem('bodify_book_variant'); } catch (e) {}
23    if (stored !== 'v1b' && stored !== 'v1b-name') stored = null;
24
25    var assigned = urlVariant || stored;
26    if (!assigned) {
27      assigned = Math.random() < 0.5 ? 'v1b' : 'v1b-name';
28      try { localStorage.setItem('bodify_book_variant', assigned); } catch (e) {}
29    }
30    window.__bodifyVersion = assigned;
31    return assigned === 'v1b-name';
32  })();
33
34  // ===== Step indices =====
35  var STEP_WELCOME   = 0;
36  var STEP_PHONE     = 1;
37  var STEP_NAME      = 2;
38  var STEP_LOCATION  = 3;
39  var STEP_VISITTYPE = 4;
40  var STEP_GOALS     = 5;
41  var STEP_TREATMENT = 6; // only reached via "skip consultation"
42  var STEP_DATETIME  = 7;
43  var STEP_EMAIL     = 8;
44  var STEP_CONFIRM   = 9;
45
46  var STEP_NAMES = {
47    0: 'Welcome',
48    1: 'Phone',
49    2: 'Name',
50    3: 'Location',
51    4: 'Visit Type',
52    5: 'Goals',
53    6: 'Treatment',
54    7: 'Date/Time',
55    8: 'Email',
56    9: 'Confirm'
57  };
58
59  var GILBERT_OPEN_DATES = [
60    '2026-08-20',
61    '2026-08-26',
62    '2026-09-01',
63    '2026-09-03',
64    '2026-09-10',
65    '2026-09-15',
66    '2026-09-18',
67    '2026-09-23',
68    '2026-10-05',
69    '2026-10-08',
70    '2026-10-13',
71    '2026-10-15',
72    '2026-10-20',
73    '2026-10-22',
74    '2026-10-28',
75    '2026-10-30'
76  ];
77
78  function isGilbertOpenDate(dateISO) {
79    return GILBERT_OPEN_DATES.indexOf(dateISO) !== -1;
80  }
81
82  // ===== Google Sheets Tracking =====
83  var TRACKING_URL = 'https://script.google.com/macros/s/AKfycbyU2QyCNLWYo19JoM3Vlo54Dhizolv0iqRTkiQFgadSVqEdCAEniHe8ieC4nCeLhuHq/exec';
84  var sessionId = 'sess_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
85
86  function trackStep(stepIndex) {
87    var channel = window.__bodifyChannel || 'direct';
88    var formVersion = window.__bodifyVersion || 'v1b';
89    var params = new URLSearchParams({
90      timestamp: new Date().toISOString(),
91      sessionId: sessionId,
92      firstName: state.data.firstName,
93      lastName: state.data.lastName,
94      phone: state.data.phone,
95      email: state.data.email,
96      location: state.data.location,
97      visitType: state.data.visitType,
98      goal: state.data.goal,
99      service: state.data.service,
100      preferredDate: state.data.preferredDate,
101      preferredTime: state.data.preferredTime,
102      lastStep: STEP_NAMES[stepIndex] || 'Unknown',
103      completed: stepIndex === STEP_CONFIRM ? 'Yes' : 'No',
104      channel: channel,
105      formVersion: formVersion,
106      cherryClicked: state.data.cherryClicked || '',
107      cherryStatus: state.data.cherryStatus || '',
108      cherryAmount: state.data.cherryAmount || ''
109    });
110    try {
111      var img = new Image();
112      img.src = TRACKING_URL + '?' + params.toString();
113    } catch (e) { /* tracking should never break the form */ }
114
115    // Push CompleteRegistration event to GTM dataLayer on form completion
116    if (stepIndex === STEP_CONFIRM) {
117      window.dataLayer = window.dataLayer || [];
118      window.dataLayer.push({
119        'event': 'CompleteRegistration',
120        'formLocation': state.data.location,
121        'formService': state.data.service,
122        'formChannel': channel
123      });
124    }
125  }
126
127  // Goal → Booker consultation mapping (in-person)
128  var GOAL_MAP = {
129    'fat-reduction':  'CoolSculpting Elite Consultation',
130    'skin-tightening':'Sofwave Consultation',
131    'build-muscle':   'CoolTone Consultation',
132    'all':            'The Bodify Method Consultation',
133    'not-sure':       'CoolSculpting Elite Consultation',
134  };
135
136  // Goal → Booker consultation mapping (virtual)
137  var VIRTUAL_GOAL_MAP = {
138    'fat-reduction':  'Virtual CoolSculpting Elite Consultation - Zoom',
139    'skin-tightening':'Virtual Sofwave Consultation - Zoom',
140    'build-muscle':   'Virtual CoolSculpting Elite Consultation - Zoom',
141    'all':            'Virtual CoolSculpting Elite Consultation - Zoom',
142    'not-sure':       'Virtual CoolSculpting Elite Consultation - Zoom',
143  };
144
145  // ===== State =====
146  var state = {
147    currentStep: 0,
148    totalSteps: 10,
149    skippedConsultation: false,
150    leadId: null, // set when lead is first created (phone step)
151    data: {
152      firstName: '',
153      lastName: '',
154      phone: '',
155      email: '',
156      location: '',
157      visitType: '',
158      talkFirst: false,
159      goal: '',
160      service: '',
161      preferredDate: '',
162      preferredTime: '',
163      sameDayService: '',
164    },
165    calendarDate: new Date(),
166  };
167
168  // ===== DOM refs =====
169  var $ = function (sel) { return document.querySelector(sel); };
170  var $$ = function (sel) { return document.querySelectorAll(sel); };
171
172  var progressFill = $('#progressFill');
173  var backBtn = $('#backBtn');
174  var steps = $$('.step');
175
176  // ===== Navigation =====
177  function goTo(nextIndex, direction) {
178    if (nextIndex < 0 || nextIndex >= state.totalSteps) return;
179
180    var currentEl = steps[state.currentStep];
181    var nextEl = steps[nextIndex];
182    var forward = direction === undefined ? nextIndex > state.currentStep : direction === 'forward';
183
184    currentEl.classList.remove('active');
185    currentEl.classList.add(forward ? 'exit-left' : 'exit-right');
186
187    nextEl.classList.remove('exit-left', 'exit-right', 'enter-left', 'enter-right');
188    nextEl.classList.add(forward ? 'enter-right' : 'enter-left');
189
190    void nextEl.offsetWidth;
191
192    nextEl.classList.remove('enter-right', 'enter-left');
193    nextEl.classList.add('active');
194
195    setTimeout(function () {
196      currentEl.classList.remove('exit-left', 'exit-right');
197    }, 350);
198
199    state.currentStep = nextIndex;
200    updateProgress();
201    updateBackBtn();
202    focusFirstInput(nextEl);
203    trackStep(nextIndex);
204
205    // Filter time slots when arriving at the date/time step
206    if (nextIndex === STEP_DATETIME && typeof filterTimeSlots === 'function') {
207      filterTimeSlots();
208    }
209  }
210
211  function updateProgress() {
212    // Calculate progress skipping the treatment step when not used,
213    // and also skipping Goals for the talk-first path.
214    var effectiveSteps = state.skippedConsultation ? 10 : (state.data.talkFirst ? 8 : 9);
215    var effectiveCurrent = state.currentStep;
216    if (state.data.talkFirst && state.currentStep > STEP_VISITTYPE) {
217      // Talk-first skips GOALS (5) and TREATMENT (6)
218      effectiveCurrent = state.currentStep - 2;
219    } else if (!state.skippedConsultation && state.currentStep > STEP_GOALS) {
220      effectiveCurrent = state.currentStep - 1;
221    }
222    var pct = effectiveCurrent === 0 ? 0 : (effectiveCurrent / (effectiveSteps - 1)) * 100;
223    progressFill.style.width = pct + '%';
224  }
225
226  function updateBackBtn() {
227    // Show back button for all active steps except Welcome and Confirm
228    if (state.currentStep >= 1 && state.currentStep <= STEP_EMAIL) {
229      backBtn.classList.add('visible');
230    } else {
231      backBtn.classList.remove('visible');
232    }
233  }
234
235  function handleBack() {
236    var cur = state.currentStep;
237    if (cur <= 0 || cur > STEP_EMAIL) return;
238
239    // Control flow: Welcome → Location → VisitType → Goals → DateTime → Phone → Name  → Email → Confirm
240    // Name-first:    Welcome → Location → VisitType → Goals → DateTime → Name  → Phone → Email → Confirm
241    // Talk-first:    Welcome → Location → VisitType → DateTime → Name → Phone → Email → Confirm
242    var nameFirstNav = state.data.talkFirst || IS_NAME_FIRST;
243    if (cur === STEP_LOCATION) {
244      goTo(STEP_WELCOME, 'back');
245    } else if (cur === STEP_VISITTYPE) {
246      goTo(STEP_LOCATION, 'back');
247    } else if (cur === STEP_GOALS) {
248      goTo(STEP_VISITTYPE, 'back');
249    } else if (cur === STEP_DATETIME) {
250      goTo(state.data.talkFirst ? STEP_VISITTYPE : STEP_GOALS, 'back');
251    } else if (cur === STEP_PHONE) {
252      goTo(nameFirstNav ? STEP_NAME : STEP_DATETIME, 'back');
253    } else if (cur === STEP_NAME) {
254      goTo(nameFirstNav ? STEP_DATETIME : STEP_PHONE, 'back');
255    } else if (cur === STEP_TREATMENT) {
256      goTo(STEP_GOALS, 'back');
257    } else if (cur === STEP_EMAIL) {
258      goTo(nameFirstNav ? STEP_PHONE : STEP_NAME, 'back');
259    } else {
260      goTo(cur - 1, 'back');
261    }
262  }
263
264  function focusFirstInput(stepEl) {
265    var input = stepEl.querySelector('input');
266    if (input) {
267      setTimeout(function () { input.focus(); }, 360);
268    }
269  }
270
271  // ===== Validation =====
272  function clearError(inputEl, errorEl) {
273    inputEl.classList.remove('invalid');
274    errorEl.textContent = '';
275  }
276
277  function showError(inputEl, errorEl, msg) {
278    inputEl.classList.add('invalid');
279    errorEl.textContent = msg;
280  }
281
282  function validateName() {
283    var first = $('#firstName');
284    var last = $('#lastName');
285    var firstErr = $('#firstNameError');
286    var lastErr = $('#lastNameError');
287    var valid = true;
288
289    clearError(first, firstErr);
290    clearError(last, lastErr);
291
292    if (!first.value.trim()) {
293      showError(first, firstErr, 'First name is required');
294      valid = false;
295    }
296    if (!last.value.trim()) {
297      showError(last, lastErr, 'Last name is required');
298      valid = false;
299    }
300
301    if (valid) {
302      state.data.firstName = first.value.trim();
303      state.data.lastName = last.value.trim();
304    }
305    return valid;
306  }
307
308  function formatPhone(value) {
309    var allDigits = value.replace(/\D/g, '');
310    var countryCode = '';
311    var digits = allDigits;
312
313    if (value.match(/^\+1/) && allDigits.length === 11 && allDigits[0] === '1') {
314      countryCode = '+1 ';
315      digits = allDigits.slice(1);
316    } else if (value.match(/^\+/)) {
317      var match = value.match(/^(\+\d{1,3})\s*(.*)/);
318      if (match) {
319        countryCode = match[1] + ' ';
320        digits = match[2].replace(/\D/g, '');
321      }
322    } else if (allDigits.length === 11 && allDigits[0] === '1') {
323      countryCode = '+1 ';
324      digits = allDigits.slice(1);
325    }
326
327    if (digits.length === 0) return countryCode;
328    if (digits.length <= 3) return countryCode + '(' + digits;
329    if (digits.length <= 6) return countryCode + '(' + digits.slice(0, 3) + ') ' + digits.slice(3);
330    return countryCode + '(' + digits.slice(0, 3) + ') ' + digits.slice(3, 6) + '-' + digits.slice(6, 10);
331  }
332
333  function validatePhone() {
334    var phone = $('#phone');
335    var phoneErr = $('#phoneError');
336    clearError(phone, phoneErr);
337
338    var val = phone.value.trim();
339    if (!val) {
340      showError(phone, phoneErr, 'Phone number is required');
341      return false;
342    }
343
344    var countryCode = '+1';
345    var numberPart = val;
346    var ccMatch = val.match(/^(\+\d{1,3})\s*(.*)/);
347    if (ccMatch) {
348      countryCode = ccMatch[1];
349      numberPart = ccMatch[2];
350    }
351
352    var digits = numberPart.replace(/\D/g, '');
353    if (!ccMatch && digits.length === 11 && digits[0] === '1') {
354      digits = digits.slice(1);
355    }
356
357    if (digits.length < 7) {
358      showError(phone, phoneErr, 'Please enter a valid phone number');
359      return false;
360    }
361
362    state.data.phone = countryCode + digits;
363    return true;
364  }
365
366  function validateEmail() {
367    var email = $('#email');
368    var emailErr = $('#emailError');
369    clearError(email, emailErr);
370
371    var val = email.value.trim();
372    if (!val) {
373      showError(email, emailErr, 'Email is required');
374      return false;
375    }
376    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) {
377      showError(email, emailErr, 'Please enter a valid email address');
378      return false;
379    }
380
381    state.data.email = val;
382    return true;
383  }
384
385  function validateDateTime() {
386    var err = $('#dateTimeError');
387    err.textContent = '';
388
389    if (!state.data.preferredDate) {
390      err.textContent = 'Please select a date';
391      return false;
392    }
393    if (!state.data.preferredTime) {
394      err.textContent = 'Please select a time';
395      return false;
396    }
397    return true;
398  }
399
400  // ===== Calendar =====
401  function renderCalendar() {
402    var year = state.calendarDate.getFullYear();
403    var month = state.calendarDate.getMonth();
404    var today = new Date();
405    today.setHours(0, 0, 0, 0);
406
407    var monthNames = [
408      'January', 'February', 'March', 'April', 'May', 'June',
409      'July', 'August', 'September', 'October', 'November', 'December',
410    ];
411
412    $('#calMonth').textContent = monthNames[month] + ' ' + year;
413
414    var firstDay = new Date(year, month, 1).getDay();
415    var daysInMonth = new Date(year, month + 1, 0).getDate();
416    var container = $('#calDays');
417    container.innerHTML = '';
418
419    for (var i = 0; i < firstDay; i++) {
420      var empty = document.createElement('span');
421      empty.className = 'cal-day empty';
422      container.appendChild(empty);
423    }
424
425    for (var d = 1; d <= daysInMonth; d++) {
426      var btn = document.createElement('button');
427      btn.className = 'cal-day';
428      btn.textContent = d;
429      var date = new Date(year, month, d);
430
431      var day = date.getDay();
432      var loc = (state.data.location || '').toLowerCase();
433      var dateISO = date.getFullYear() + '-' + String(date.getMonth()+1).padStart(2,'0') + '-' + String(date.getDate()).padStart(2,'0');
434      var isGilbertClosed = loc === 'gilbert' && !isGilbertOpenDate(dateISO);
435      var scottsdaleClosed = ['2026-04-04','2026-04-18','2026-05-02','2026-05-16','2026-05-30'];
436      var isScottsdaleClosed = loc === 'scottsdale' && (day === 1 || scottsdaleClosed.indexOf(dateISO) !== -1);
437      var maxDate = new Date(); maxDate.setDate(maxDate.getDate() + 60); maxDate.setHours(23,59,59,999);
438      if (date < today || date > maxDate || day === 0 || isGilbertClosed || isScottsdaleClosed) {
439        btn.classList.add('disabled');
440      } else {
441        (function (dt, b) {
442          b.addEventListener('click', function () { selectDate(dt, b); });
443        })(date, btn);
444      }
445
446      if (date.getTime() === today.getTime()) {
447        btn.classList.add('today');
448      }
449
450      if (state.data.preferredDate) {
451        var sel = new Date(state.data.preferredDate + 'T00:00:00');
452        if (date.getFullYear() === sel.getFullYear() &&
453            date.getMonth() === sel.getMonth() &&
454            date.getDate() === sel.getDate()) {
455          btn.classList.add('selected');
456        }
457      }
458
459      container.appendChild(btn);
460    }
461  }
462
463  function selectDate(date, btnEl) {
464    var yyyy = date.getFullYear();
465    var mm = String(date.getMonth() + 1).padStart(2, '0');
466    var dd = String(date.getDate()).padStart(2, '0');
467    state.data.preferredDate = yyyy + '-' + mm + '-' + dd;
468
469    $$('.cal-day').forEach(function (el) { el.classList.remove('selected'); });
470    btnEl.classList.add('selected');
471
472    var tg = document.getElementById('timeGrid');
473    var ld = document.getElementById('availabilityLoading');
474    if (tg) tg.style.display = 'none';
475    if (ld) ld.style.display = 'flex';
476    $('#timeSlots').classList.add('visible');
477    $('#dateTimeError').textContent = '';
478
479    // Request availability for this specific date from the extension (if running locally)
480    try {
481      window.postMessage({
482        type: 'bodify-request-date-availability',
483        date: state.data.preferredDate,
484        location: state.data.location
485      }, '*');
486    } catch (e) { /* extension not installed */ }
487
488    // Fetch availability from Google Sheet (for external users without extension)
489    fetchSheetAvailability(state.data.preferredDate, state.data.location);
490
491    // Filter with whatever data we have (may update when fetch responds)
492    if (typeof filterTimeSlots === 'function') {
493      filterTimeSlots();
494    }
495  }
496
497  // ===== Extension Messaging =====
498  function buildLeadObject() {
499    return {
500      id: state.leadId || ('lead_' + Date.now()),
501      firstName: state.data.firstName,
502      lastName: state.data.lastName,
503      phone: state.data.phone,
504      email: state.data.email,
505      location: state.data.location,
506      visitType: state.data.visitType,
507      goal: state.data.goal,
508      service: state.data.service,
509      preferredDate: state.data.preferredDate,
510      preferredTime: state.data.preferredTime,
511      sameDayService: state.data.sameDayService,
512      createdAt: state.leadCreatedAt || new Date().toISOString(),
513      status: 'new',
514    };
515  }
516
517  function notifyExtension(type, lead) {
518    try {
519      window.postMessage({ type: 'bodify-' + type, lead: lead }, '*');
520    } catch (e) { /* extension not installed */ }
521  }
522
523  function createLeadOnPhone() {
524    state.leadId = 'lead_' + Date.now();
525    state.leadCreatedAt = new Date().toISOString();
526    var lead = buildLeadObject();
527    lead.status = 'new';
528    notifyExtension('new-lead', lead);
529
530    // Also save to localStorage
531    var leads = JSON.parse(localStorage.getItem('bodify_leads') || '[]');
532    leads.push(lead);
533    localStorage.setItem('bodify_leads', JSON.stringify(leads));
534  }
535
536  function updateLeadProgress() {
537    if (!state.leadId) return;
538    var lead = buildLeadObject();
539    lead.status = 'in-progress';
540    notifyExtension('update-lead', lead);
541
542    // Update in localStorage
543    var leads = JSON.parse(localStorage.getItem('bodify_leads') || '[]');
544    for (var i = 0; i < leads.length; i++) {
545      if (leads[i].id === state.leadId) {
546        leads[i] = lead;
547        break;
548      }
549    }
550    localStorage.setItem('bodify_leads', JSON.stringify(leads));
551  }
552
553  function completeLeadOnEmail() {
554    if (!state.leadId) return;
555    var lead = buildLeadObject();
556    lead.status = 'booked';
557    notifyExtension('complete-lead', lead);
558
559    // Update in localStorage
560    var leads = JSON.parse(localStorage.getItem('bodify_leads') || '[]');
561    for (var i = 0; i < leads.length; i++) {
562      if (leads[i].id === state.leadId) {
563        leads[i] = lead;
564        break;
565      }
566    }
567    localStorage.setItem('bodify_leads', JSON.stringify(leads));
568
569    return lead;
570  }
571
572  // ===== Save Lead =====
573  function saveLead() {
574    var lead = {
575      id: 'lead_' + Date.now(),
576      firstName: state.data.firstName,
577      lastName: state.data.lastName,
578      phone: state.data.phone,
579      email: state.data.email,
580      location: state.data.location,
581      visitType: state.data.visitType,
582      goal: state.data.goal,
583      service: state.data.service,
584      preferredDate: state.data.preferredDate,
585      preferredTime: state.data.preferredTime,
586      sameDayService: state.data.sameDayService,
587      createdAt: new Date().toISOString(),
588      status: 'pending',
589    };
590
591    var leads = JSON.parse(localStorage.getItem('bodify_leads') || '[]');
592    leads.push(lead);
593    localStorage.setItem('bodify_leads', JSON.stringify(leads));
594
595    // Notify Chrome extension if available
596    try {
597      window.postMessage({ type: 'bodify-new-lead', lead: lead }, '*');
598    } catch (e) { /* extension not installed */ }
599
600    return lead;
601  }
602
603  // ===== Render Confirmation =====
604  function renderSummary(lead) {
605    var summary = $('#summary');
606    var items = [
607      { label: 'Name', value: lead.firstName + ' ' + lead.lastName },
608      { label: 'Phone', value: formatPhone(lead.phone.replace('+1', '')) },
609      { label: 'Email', value: lead.email },
610      { label: 'Visit', value: lead.visitType === 'virtual' ? 'Virtual' : (lead.visitType === 'talk-first' ? 'Phone Consultation' : 'In-Person') },
611      { label: 'Service', value: lead.service },
612      { label: 'Location', value: lead.location },
613      { label: 'Date', value: formatDisplayDate(lead.preferredDate) },
614      { label: 'Time', value: lead.preferredTime },
615    ];
616    if (lead.sameDayService) {
617      var sdMap = { yes: 'Yes', no: 'No', maybe: 'Maybe' };
618      items.push({ label: 'Same-Day', value: sdMap[lead.sameDayService] || lead.sameDayService });
619    }
620    var editableFields = ['Name', 'Phone', 'Email'];
621    summary.innerHTML = items
622      .map(function (item) {
623        var isEditable = editableFields.indexOf(item.label) !== -1;
624        return '<div class="summary-item' + (isEditable ? ' summary-editable' : '') + '"' +
625          (isEditable ? ' data-field="' + item.label.toLowerCase() + '"' : '') + '>' +
626          '<span class="summary-label">' + item.label + '</span>' +
627          '<span class="summary-value">' + item.value +
628          (isEditable ? '<svg class="edit-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>' : '') +
629          '</span></div>';
630      })
631      .join('');
632
633    // Attach click handlers for editable fields
634    summary.querySelectorAll('.summary-editable').forEach(function (row) {
635      row.addEventListener('click', function () {
636        var field = row.dataset.field;
637        var valueEl = row.querySelector('.summary-value');
638        var currentText = valueEl.textContent.trim();
639
640        var input = document.createElement('input');
641        input.type = field === 'email' ? 'email' : 'text';
642        input.className = 'summary-edit-input';
643        input.value = currentText;
644
645        valueEl.innerHTML = '';
646        valueEl.appendChild(input);
647        input.focus();
648        input.select();
649
650        function save() {
651          var val = input.value.trim();
652          if (!val) return;
653
654          if (field === 'name') {
655            var parts = val.split(/\s+/);
656            state.data.firstName = parts[0] || '';
657            state.data.lastName = parts.slice(1).join(' ') || '';
658            lead.firstName = state.data.firstName;
659            lead.lastName = state.data.lastName;
660          } else if (field === 'phone') {
661            var digits = val.replace(/\D/g, '');
662            if (digits.length === 11 && digits[0] === '1') digits = digits.slice(1);
663            if (digits.length !== 10) return;
664            state.data.phone = '+1' + digits;
665            lead.phone = state.data.phone;
666          } else if (field === 'email') {
667            if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) return;
668            state.data.email = val;
669            lead.email = val;
670          }
671
672          // Update localStorage
673          var leads = JSON.parse(localStorage.getItem('bodify_leads') || '[]');
674          for (var i = leads.length - 1; i >= 0; i--) {
675            if (leads[i].id === lead.id) {
676              leads[i] = lead;
677              break;
678            }
679          }
680          localStorage.setItem('bodify_leads', JSON.stringify(leads));
681
682          renderSummary(lead);
683        }
684
685        input.addEventListener('blur', save);
686        input.addEventListener('keydown', function (e) {
687          if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
688          if (e.key === 'Escape') { renderSummary(lead); }
689        });
690      });
691    });
692  }
693
694  function formatDisplayDate(dateStr) {
695    var parts = dateStr.split('-').map(Number);
696    var date = new Date(parts[0], parts[1] - 1, parts[2]);
697    return date.toLocaleDateString('en-US', {
698      weekday: 'short',
699      month: 'short',
700      day: 'numeric',
701      year: 'numeric',
702    });
703  }
704
705  // ===== Event Listeners =====
706  function init() {
707    // Welcome — go to Location first
708    $('#startBtn').addEventListener('click', function () { goTo(STEP_LOCATION); });
709
710    // Back
711    backBtn.addEventListener('click', handleBack);
712
713    // Phone
714    $('#phone').addEventListener('input', function (e) {
715      var pos = e.target.selectionStart;
716      var prevLen = e.target.value.length;
717      e.target.value = formatPhone(e.target.value);
718      var newLen = e.target.value.length;
719      e.target.setSelectionRange(pos + (newLen - prevLen), pos + (newLen - prevLen));
720    });
721
722    $('#phoneNext').addEventListener('click', function () {
723      if (validatePhone()) {
724        if (state.data.talkFirst || IS_NAME_FIRST) {
725          // Name was captured on the previous step; phone is the last identity
726          // field, so create the lead now and continue to email.
727          createLeadOnPhone();
728          goTo(STEP_EMAIL, 'forward');
729        } else {
730          createLeadOnPhone();
731          goTo(STEP_NAME);
732        }
733      }
734    });
735
736    // Name — control: → Email; name-first / talk-first: → Phone
737    $('#nameNext').addEventListener('click', function () {
738      if (validateName()) {
739        if (state.data.talkFirst || IS_NAME_FIRST) {
740          // Phone hasn't been captured yet — no lead exists; just advance.
741          goTo(STEP_PHONE, 'forward');
742        } else {
743          updateLeadProgress();
744          goTo(STEP_EMAIL);
745        }
746      }
747    });
748
749    // Location — auto-advance to Visit Type
750    $$('.location-card').forEach(function (card) {
751      card.addEventListener('click', function () {
752        $$('.location-card').forEach(function (c) { c.classList.remove('selected'); });
753        card.classList.add('selected');
754        state.data.location = card.dataset.location;
755        renderCalendar(); // Re-render with location-specific closed days
756        updateLeadProgress();
757        setTimeout(function () { goTo(STEP_VISITTYPE); }, 350);
758      });
759    });
760
761    // Visit Type — auto-advance
762    $$('.visit-type-card').forEach(function (card) {
763      card.addEventListener('click', function () {
764        $$('.visit-type-card').forEach(function (c) { c.classList.remove('selected'); });
765        card.classList.add('selected');
766        state.data.visitType = card.dataset.visit;
767
768        // Talk-to-team: force Phoenix + Bodify Intro Call, skip Goals/Treatment
769        if (card.dataset.visit === 'talk-first') {
770          state.data.talkFirst = true;
771          state.data.location = 'Phoenix';
772          state.data.service = 'Bodify Intro Call';
773          state.data.goal = '';
774          state.data.sameDayService = '';
775          var skipBtnTf = document.getElementById('skipToTreatment');
776          if (skipBtnTf) skipBtnTf.style.display = 'none';
777          var sameDayElTf = document.querySelector('.same-day-question');
778          if (sameDayElTf) sameDayElTf.style.display = 'none';
779          renderCalendar(); // Phoenix-specific closed days
780          updateLeadProgress();
781          setTimeout(function () { goTo(STEP_DATETIME); }, 350);
782          return;
783        }
784
785        state.data.talkFirst = false;
786        updateLeadProgress();
787
788        // Hide "I Already Know What I Want!" for virtual (treatments are in-person only)
789        var skipBtn = document.getElementById('skipToTreatment');
790        if (skipBtn) {
791          skipBtn.style.display = card.dataset.visit === 'virtual' ? 'none' : '';
792        }
793
794        // Hide same-day service question for virtual
795        var sameDayEl = document.querySelector('.same-day-question');
796        if (sameDayEl) {
797          sameDayEl.style.display = card.dataset.visit === 'virtual' ? 'none' : '';
798        }
799        if (card.dataset.visit === 'virtual') {
800          state.data.sameDayService = '';
801        }
802
803        setTimeout(function () { goTo(STEP_GOALS); }, 350);
804      });
805    });
806
807    // Goals — auto-advance, map to service
808    $$('.goal-card').forEach(function (card) {
809      card.addEventListener('click', function () {
810        $$('.goal-card').forEach(function (c) { c.classList.remove('selected'); });
811        card.classList.add('selected');
812        var goal = card.dataset.goal;
813        state.data.goal = goal;
814        if (state.data.visitType === 'virtual') {
815          state.data.service = VIRTUAL_GOAL_MAP[goal] || 'Virtual CoolSculpting Elite Consultation - Zoom';
816        } else {
817          state.data.service = GOAL_MAP[goal] || 'CoolSculpting Elite Consultation';
818        }
819        state.skippedConsultation = false;
820        updateLeadProgress();
821        setTimeout(function () { goTo(STEP_DATETIME); }, 350);
822      });
823    });
824
825    // Skip consultation → book treatment
826    $('#skipToTreatment').addEventListener('click', function () {
827      state.skippedConsultation = true;
828      state.data.goal = 'treatment';
829      goTo(STEP_TREATMENT);
830    });
831
832    // Treatment cards — auto-advance
833    $$('.treatment-card').forEach(function (card) {
834      card.addEventListener('click', function () {
835        $$('.treatment-card').forEach(function (c) { c.classList.remove('selected'); });
836        card.classList.add('selected');
837        state.data.service = card.dataset.treatment;
838        updateLeadProgress();
839        setTimeout(function () { goTo(STEP_DATETIME); }, 350);
840      });
841    });
842
843    // Calendar nav
844    $('#calPrev').addEventListener('click', function () {
845      state.calendarDate.setDate(1);
846      state.calendarDate.setMonth(state.calendarDate.getMonth() - 1);
847      renderCalendar();
848    });
849
850    $('#calNext').addEventListener('click', function () {
851      state.calendarDate.setDate(1);
852      state.calendarDate.setMonth(state.calendarDate.getMonth() + 1);
853      renderCalendar();
854    });
855
856    // Time slots
857    $$('.time-slot').forEach(function (slot) {
858      slot.addEventListener('click', function () {
859        $$('.time-slot').forEach(function (s) { s.classList.remove('selected'); });
860        slot.classList.add('selected');
861        state.data.preferredTime = slot.dataset.time;
862        $('#dateTimeError').textContent = '';
863      });
864    });
865
866    // Date/Time continue — control: → Phone; name-first / talk-first: → Name
867    $('#dateTimeNext').addEventListener('click', function () {
868      if (validateDateTime()) {
869        updateLeadProgress();
870        goTo((state.data.talkFirst || IS_NAME_FIRST) ? STEP_NAME : STEP_PHONE, 'forward');
871      }
872    });
873
874    // Same-day service buttons
875    $$('.same-day-btn').forEach(function (btn) {
876      btn.addEventListener('click', function () {
877        $$('.same-day-btn').forEach(function (b) { b.classList.remove('selected'); });
878        btn.classList.add('selected');
879        state.data.sameDayService = btn.dataset.sameday;
880      });
881    });
882
883    // Email — complete lead → confirmation
884    $('#emailNext').addEventListener('click', function () {
885      if (validateEmail()) {
886        var lead = completeLeadOnEmail();
887        renderSummary(lead);
888        goTo(STEP_CONFIRM);
889      }
890    });
891
892    // ===== Cherry Application — iframe variant =====
893    // Clicking Apply on ip-done opens Cherry's hosted application in an iframe.
894    // 3s fallback timer reveals "Open in new tab" if Cherry's X-Frame-Options blocks embedding.
895    var CHERRY_IFRAME_BASE = 'https://pay.withcherry.com/bodify/phone';
896    $('#cherryYesBtn').addEventListener('click', function () {
897      state.data.cherryClicked = 'Yes';
898      trackStep(STEP_CONFIRM);
899
900      var overlay = $('#cherryIframeOverlay');
901      var iframe = $('#cherryIframe');
902      var fallback = $('#iframeFallback');
903      var openLink = $('#iframeOpenNew');
904
905      iframe.src = CHERRY_IFRAME_BASE;
906      openLink.href = CHERRY_IFRAME_BASE;
907      fallback.hidden = true;
908
909      var loaded = false;
910      iframe.addEventListener('load', function () { loaded = true; }, { once: true });
911      setTimeout(function () { if (!loaded) fallback.hidden = false; }, 3000);
912
913      overlay.style.display = '';
914      window.scrollTo(0, 0);
915    });
916
917    // Reset
918    $('#resetBtn').addEventListener('click', function () {
919      state.data = {
920        firstName: '',
921        lastName: '',
922        phone: '',
923        email: '',
924        location: '',
925        visitType: '',
926        talkFirst: false,
927        goal: '',
928        service: '',
929        preferredDate: '',
930        preferredTime: '',
931        sameDayService: '',
932      };
933      state.calendarDate = new Date();
934      state.skippedConsultation = false;
935      state.leadId = null;
936      state.leadCreatedAt = null;
937
938      $$('input').forEach(function (inp) {
939        inp.value = '';
940        inp.classList.remove('invalid');
941      });
942      $$('.field-error').forEach(function (el) { el.textContent = ''; });
943      $$('.location-card').forEach(function (c) { c.classList.remove('selected'); });
944      $$('.visit-type-card').forEach(function (c) { c.classList.remove('selected'); });
945      $$('.goal-card').forEach(function (c) { c.classList.remove('selected'); });
946      $$('.treatment-card').forEach(function (c) { c.classList.remove('selected'); });
947      $$('.time-slot').forEach(function (s) { s.classList.remove('selected'); });
948      $$('.same-day-btn').forEach(function (b) { b.classList.remove('selected'); });
949      $$('.cal-day').forEach(function (d) { d.classList.remove('selected'); });
950      $('#timeSlots').classList.remove('visible');
951
952      renderCalendar();
953      goTo(STEP_WELCOME, 'back');
954    });
955
956    // Keyboard
957    document.addEventListener('keydown', function (e) {
958      if (e.key === 'Enter') {
959        var step = state.currentStep;
960        if (step === STEP_WELCOME) $('#startBtn').click();
961        else if (step === STEP_PHONE) $('#phoneNext').click();
962        else if (step === STEP_NAME) $('#nameNext').click();
963        else if (step === STEP_DATETIME) $('#dateTimeNext').click();
964        else if (step === STEP_EMAIL) $('#emailNext').click();
965      }
966    });
967
968    // ===== Availability from Booker extension =====
969    // Listen for real-time availability data from the Chrome extension
970    window.addEventListener('message', function (event) {
971      if (!event.data || event.data.type !== 'bodify-availability') return;
972
973      var availability = event.data.availability;
974      if (!availability || Object.keys(availability).length === 0) return;
975
976      console.log('[Bodify Intake] Received availability:', availability);
977
978      // Store it for use when filtering time slots
979      state.availability = availability;
980
981      // If we're on the date/time step, apply the filter now
982      if (state.currentStep === STEP_DATETIME) {
983        filterTimeSlots();
984      }
985    });
986
987    // Init
988    updateProgress();
989    updateBackBtn();
990    renderCalendar();
991  }
992
993  // ===== Fetch availability from Google Sheet =====
994  var AVAILABILITY_URL = 'https://script.google.com/macros/s/AKfycbyU2QyCNLWYo19JoM3Vlo54Dhizolv0iqRTkiQFgadSVqEdCAEniHe8ieC4nCeLhuHq/exec';
995
996  function fetchSheetAvailability(date, location) {
997    var params = new URLSearchParams({
998      action: 'readAvailability',
999      location: location || '',
1000      _: String(Date.now())
1001    });
1002
1003    fetch(AVAILABILITY_URL + '?' + params.toString(), { cache: 'no-store' })
1004      .then(function (res) { return res.json(); })
1005      .then(function (data) {
1006        var availability = normalizeSheetAvailability((data && data.availability) || {});
1007        state._sheetFetched = true;
1008        if (Object.keys(availability).length > 0) {
1009          console.log('[Bodify Intake] Sheet availability loaded:', Object.keys(availability));
1010          if (!state.availability) state.availability = {};
1011          for (var l in availability) {
1012            if (!state.availability[l]) state.availability[l] = {};
1013            for (var d in availability[l]) {
1014              state.availability[l][d] = availability[l][d];
1015            }
1016          }
1017          if (state.currentStep === STEP_DATETIME) {
1018            filterTimeSlots();
1019          }
1020        } else {
1021          console.log('[Bodify Intake] No availability data found for', location);
1022        }
1023      })
1024      .catch(function (err) {
1025        console.log('[Bodify Intake] Sheet availability fetch error:', err);
1026        state._sheetFetched = true;
1027      });
1028  }
1029
1030  // Parse a single CSV row handling quoted fields with commas
1031  function parseCSVRow(line) {
1032    var result = [];
1033    var current = '';
1034    var inQuotes = false;
1035    for (var i = 0; i < line.length; i++) {
1036      var ch = line[i];
1037      if (ch === '"') {
1038        inQuotes = !inQuotes;
1039      } else if (ch === ',' && !inQuotes) {
1040        result.push(current);
1041        current = '';
1042      } else {
1043        current += ch;
1044      }
1045    }
1046    result.push(current);
1047    return result;
1048  }
1049
1050  function normalizeSheetAvailability(availability) {
1051    var normalized = {};
1052    if (!availability || typeof availability !== 'object') return normalized;
1053    for (var loc in availability) {
1054      var locationDates = availability[loc];
1055      if (!locationDates || typeof locationDates !== 'object') continue;
1056      if (!normalized[loc]) normalized[loc] = {};
1057      for (var dateKey in locationDates) {
1058        var entry = locationDates[dateKey] || {};
1059        var slots = entry.slots || entry;
1060        normalized[loc][dateKey] = { slots: {} };
1061        for (var col in slots) {
1062          if (!slots.hasOwnProperty(col)) continue;
1063          var values = Array.isArray(slots[col]) ? slots[col] : String(slots[col] || '').split(',');
1064          normalized[loc][dateKey].slots[col] = values.map(normalizeSheetTime).filter(
1064Boolean);
1065        }
1066      }
1067    }
1068    return normalized;
1069  }
1070
1071  function normalizeSheetTime(value) {
1072    var text = String(value || '').trim();
1073    if (!text) return '';
1074    var direct = text.match(/^(\d{1,2}):(\d{2})\s*([AP]M)$/i);
1075    if (direct) {
1076      return formatTimeLabel(parseInt(direct[1], 10), parseInt(direct[2], 10), direct[3]);
1077    }
1078    var parsed = new Date(text);
1079    if (!isNaN(parsed.getTime()) && parsed.getFullYear() < 1901) {
1080      return formatTimeLabel(parsed.getHours(), parsed.getMinutes());
1081    }
1082    return text;
1083  }
1084
1085  function formatTimeLabel(hours, minutes, meridiem) {
1086    if (meridiem) {
1087      return String(hours) + ':' + String(minutes).padStart(2, '0') + ' ' + meridiem.toUpperCase();
1088    }
1089    var suffix = hours >= 12 ? 'PM' : 'AM';
1090    var displayHour = hours % 12;
1091    if (displayHour === 0) displayHour = 12;
1092    return displayHour + ':' + String(minutes).padStart(2, '0') + ' ' + suffix;
1093  }
1094
1095  // ===== Filter time slots based on Booker availability =====
1096  // Availability structure: { "Phoenix": { "Thu, Mar 12, 2026": { slots: {...} }, ... }, ... }
1097  function filterTimeSlots() {
1098    var loadingEl = document.getElementById('availabilityLoading');
1099    var timeGrid = document.getElementById('timeGrid');
1100    var noAvailEl = document.getElementById('noAvailability');
1101
1102    // Start with spinner visible, time grid hidden
1103    if (noAvailEl) noAvailEl.style.display = 'none';
1104    if (timeGrid) timeGrid.style.display = 'none';
1105    if (loadingEl) loadingEl.style.display = 'flex';
1106
1107    var isGilbert = (state.data.location || '').toLowerCase() === 'gilbert';
1108    // Gilbert doesn't need service selection — always uses Jessica column
1109    if (!state.availability || (!state.data.service && !isGilbert) || !state.data.preferredDate) {
1110      if (loadingEl) loadingEl.style.display = 'none';
1111      return;
1112    }
1113
1114    var serviceColumns = state.data.service ? SERVICE_COLUMN_MAP[state.data.service] : null;
1115    if (!serviceColumns && !isGilbert) return;
1116
1117    var selectedDate = state.data.preferredDate; // "2026-03-12"
1118
1119    // Find matching location data
1120    var matchingDateData = null;
1121    for (var locationKey in state.availability) {
1122      var loc = (state.data.location || '').toLowerCase();
1123      var key = locationKey.toLowerCase();
1124      var matchesLocation = !loc || key.indexOf(loc) !== -1 || loc.indexOf(key) !== -1;
1125      if (!matchesLocation) continue;
1126
1127      var locationDates = state.availability[locationKey];
1128      if (!locationDates || typeof locationDates !== 'object') continue;
1129
1130      // Search through all scraped dates for this location
1131      for (var scrapedDate in locationDates) {
1132        if (datesMatch(scrapedDate, selectedDate)) {
1133          matchingDateData = locationDates[scrapedDate];
1134          break;
1135        }
1136      }
1137      if (matchingDateData) break;
1138    }
1139
1140    if (!matchingDateData) {
1141      // Show loading spinner — data not yet scraped for this date
1142      if (loadingEl) loadingEl.style.display = 'flex';
1143      if (timeGrid) timeGrid.style.display = 'none';
1144      console.log('[Bodify Intake] No data for', selectedDate, '— showing loader (requesting from Booker...)');
1145
1146      // Fallback: after 10 seconds, show no availability
1147      if (!state._loadingTimer) {
1148        state._loadingTimer = setTimeout(function () {
1149          state._loadingTimer = null;
1150          if (loadingEl && loadingEl.style.display !== 'none') {
1151            console.log('[Bodify Intake] Loading timeout — showing no availability');
1152            if (loadingEl) loadingEl.style.display = 'none';
1153            if (timeGrid) timeGrid.style.display = 'none';
1154            if (noAvailEl) noAvailEl.style.display = 'block';
1155          }
1156        }, 10000);
1157      }
1158      return;
1159    }
1160
1161    // Data found — cancel any loading timer
1162    if (state._loadingTimer) {
1163      clearTimeout(state._loadingTimer);
1164      state._loadingTimer = null;
1165    }
1166
1167    console.log('[Bodify Intake] Filtering for', state.data.service, 'at', state.data.location, 'on', selectedDate);
1168    console.log('[Bodify Intake] matchingDateData:', JSON.stringify(matchingDateData));
1169
1170    // Collect available times across relevant columns
1171    var availableTimes = {};
1172    var slots = matchingDateData.slots || matchingDateData;
1173
1174    if (isGilbert) {
1175      // Gilbert: ONLY use Jessica column
1176      var jessicaSlots = slots['Jessica'];
1177      if (jessicaSlots && Array.isArray(jessicaSlots)) {
1178        jessicaSlots.forEach(function (time) {
1179          availableTimes[time] = true;
1180        });
1181      }
1182    } else if (!serviceColumns) {
1183      for (var colName in slots) {
1184        var colSlots = slots[colName];
1185        if (colSlots && Array.isArray(colSlots)) {
1186          colSlots.forEach(function (time) {
1187            availableTimes[time] = true;
1188          });
1189        }
1190      }
1191    } else {
1192      serviceColumns.forEach(function (colName) {
1193        var colSlots = slots[colName];
1194        if (colSlots && Array.isArray(colSlots)) {
1195          colSlots.forEach(function (time) {
1196            availableTimes[time] = true;
1197          });
1198        }
1199      });
1200    }
1201
1202    // Gilbert opens at 10 AM — never offer 9 AM
1203    if (isGilbert) delete availableTimes['9:00 AM'];
1204
1205    // Weekend hour restrictions — hard rules
1206    var loc = (state.data.location || '').toLowerCase();
1207    var selParts = selectedDate.split('-');
1208    var selDateObj = new Date(parseInt(selParts[0]), parseInt(selParts[1]) - 1, parseInt(selParts[2]));
1209    var selDay = selDateObj.getDay();
1210    if (loc === 'scottsdale' && selDay === 6) {
1211      delete availableTimes['4:00 PM'];
1212      delete availableTimes['5:00 PM'];
1213    }
1214    if (loc === 'phoenix' && selDay === 6) {
1215      delete availableTimes['3:00 PM'];
1216      delete availableTimes['4:00 PM'];
1217      delete availableTimes['5:00 PM'];
1218    }
1219
1220    var availableList = Object.keys(availableTimes);
1221    console.log('[Bodify Intake] Available times:', availableList);
1222
1223    if (loadingEl) loadingEl.style.display = 'none';
1224
1225    if (availableList.length === 0) {
1226      console.log('[Bodify Intake] No available slots for this date/service — showing no availability');
1227      if (timeGrid) timeGrid.style.display = 'none';
1228      if (noAvailEl) noAvailEl.style.display = 'block';
1229      return;
1230    }
1231
1232    // Show only available slots
1233    if (timeGrid) timeGrid.style.display = '';
1234    $$('.time-slot').forEach(function (slot) {
1235      var time = slot.dataset.time;
1236      slot.style.display = availableTimes[time] ? '' : 'none';
1237    });
1238  }
1239
1240  // Compare scraped date string ("Thu, Mar 12, 2026") to selected date ("2026-03-12")
1241  function datesMatch(scrapedDate, selectedDate) {
1242    if (!scrapedDate || !selectedDate) return false;
1243    try {
1244      var scraped = new Date(scrapedDate);
1245      var parts = selectedDate.split('-');
1246      var selected = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
1247      return scraped.getFullYear() === selected.getFullYear() &&
1248             scraped.getMonth() === selected.getMonth() &&
1249             scraped.getDate() === selected.getDate();
1250    } catch (e) {
1251      return false;
1252    }
1253  }
1254
1255  // Service → Booker column mapping (mirrors booker-content.js)
1256  var SERVICE_COLUMN_MAP = {
1257    // In-person consultations
1258    'CoolSculpting Elite Consultation': ['CoolSculpting 1', 'CoolSculpting 2'],
1259    'CoolTone Consultation': ['CoolSculpting 1', 'CoolSculpting 2'],
1260    'Sofwave Consultation': ['Sofwave/Morpheus8'],
1261    'The Bodify Method Consultation': ['CoolSculpting 1', 'CoolSculpting 2'],
1262    // In-person treatments ("I Already Know What I Want!")
1263    'CoolSculpting Treatment': ['CoolSculpting 1', 'CoolSculpting 2'],
1264    'Morpheus8 Boost': ['Sofwave/Morpheus8'],
1265    'Sofwave Treatment': ['Sofwave/Morpheus8'],
1266    // Virtual consultations
1267    'Virtual CoolSculpting Elite Consultation - Zoom': ['Virtual Consult'],
1268    'Virtual Morpheus8 Consultation - Zoom': ['Virtual Consult'],
1269    'Virtual Sofwave Consultation - Zoom': ['Virtual Consult'],
1270    // Talk-to-team phone consult — Phoenix only, Virtual Consult column
1271    'Bodify Intro Call': ['Virtual Consult'],
1272  };
1273
1274  if (document.readyState === 'loading') {
1275    document.addEventListener('DOMContentLoaded', init);
1276  } else {
1277    init();
1278  }
1279})();

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.