PageSourceSearch

https://atlasautoglass.com/quote/quote-p1.js

js atlasautoglass.com collected 2026-10-04 01:49:06 UTC 35,276 bytes, 1,205 lines download raw bytes

1// STATE
2// ═══════════════════════════════════════════
3var formState = {
4  damage: "",
5  damageLabel: "",
6  damages: [], // Array of { type, label } for multi-glass support
7  sideWindows: [], // Selected side window positions
8  year: "",
9  make: "",
10  model: "",
11  bodyType: "",
12  name: "",
13  phone: "",
14  insurance: false,
15  vin: "",
16  plate: "",
17  // Per-platform S3 URLs keyed by platform.name — each backend uploads to
18  // its own bucket and returns its own URL, which must be sent back to
19  // that same platform when attaching the document.
20  photoUrls: {},
21};
22
23// ═══════════════════════════════════════════
24// LEAD CAPTURE ENGINE v2
25// Phase 1: Google Sheet (live now)
26// Phase 2: CRM API — multi-platform fan-out
27//
28// Platform config lives in tracking.js (window.ATLAS_PLATFORMS) so there
29// is one source of truth for dev/prod/future environments.
30// ═══════════════════════════════════════════
31
32// ── CONFIGURATION ──
33var GOOGLE_SCRIPT_URL =
34  "https://script.google.com/macros/s/AKfycbxUxXfRlzzkfVbQSK1tv3qe6tPOnB-VrrwXOqglJjATNb803i6Z5sq_VndbkZu9H9d8Lg/exec"; // ← Phase 1: Apps Script Web App URL
35
36function getEnabledPlatforms() {
37  return (window.ATLAS_PLATFORMS || []).filter(function (p) {
38    return p && p.enabled && p.apiUrl;
39  });
40}
41
42// ── Lead ID: generated client-side so Sheet + CRM use the same one ──
43var currentLeadId = null;
44function generateLeadId() {
45  var ts = Date.now().toString(36).toUpperCase();
46  var rand = Math.random().toString(36).substring(2, 6).toUpperCase();
47  return "ATL-" + ts + "-" + rand;
48}
49
50// ── UTM + GCLID tracking ──
51// Reads from current URL first, then falls back to cookies set by tracking.js
52// on any prior page in the same session (90-day cookie expiry).
53function getTrackingParams() {
54  function getCookieVal(name) {
55    var match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)"));
56    return match ? decodeURIComponent(match[2]) : "";
57  }
58  var params = new URLSearchParams(window.location.search);
59  var gclid = params.get("gclid") || getCookieVal("_atlas_gclid") || "";
60  // On iOS Google sends wbraid or gbraid INSTEAD OF gclid; without these an
61  // iPhone ad click reached the portal as an unattributed lead.
62  var wbraid = params.get("wbraid") || getCookieVal("_atlas_wbraid") || "";
63  var gbraid = params.get("gbraid") || getCookieVal("_atlas_gbraid") || "";
64  var fbclid = params.get("fbclid") || getCookieVal("_atlas_fbclid") || "";
65  var msclkid = params.get("msclkid") || getCookieVal("_atlas_msclkid") || "";
66  var utmSource =
67    params.get("utm_source") || getCookieVal("_atlas_utm_source") || "";
68  var utmMedium =
69    params.get("utm_medium") || getCookieVal("_atlas_utm_medium") || "";
70  var utmCampaign =
71    params.get("utm_campaign") || getCookieVal("_atlas_utm_campaign") || "";
72  var utmContent =
73    params.get("utm_content") || getCookieVal("_atlas_utm_content") || "";
74  var utmTerm = params.get("utm_term") || getCookieVal("_atlas_utm_term") || "";
75  var landingPage =
76    getCookieVal("_atlas_landing_page") || params.get("lp") || "";
77  var referrer = document.referrer || "";
78  // The external site the visit came from, captured by tracking.js on landing.
79  // document.referrer here is usually our own previous page.
80  var landingReferrer = getCookieVal("_atlas_referrer") || "";
81  var device = /Mobi|Android/i.test(navigator.userAgent) ? "mobile" : "desktop";
82
83  // Derive a human-readable channel label for the Sheet
84  var channel = "Direct";
85  if (gclid || wbraid || gbraid) channel = "Google Ads";
86  else if (msclkid) channel = "Bing Ads";
87  else if (fbclid) channel = "Meta";
88  else if (utmSource === "bing" && utmMedium === "cpc") channel = "Bing Ads";
89  else if (utmMedium === "cpc") channel = (utmSource || "unknown") + " / Paid";
90  else if (utmMedium === "organic")
91    channel = (utmSource || "unknown") + " / Organic";
92  else if (utmSource && utmMedium) channel = utmSource + " / " + utmMedium;
93  else if (utmSource) channel = utmSource;
94  else if (referrer) channel = "Referral";
95
96  return {
97    gclid: gclid,
98    wbraid: wbraid,
99    gbraid: gbraid,
100    fbclid: fbclid,
101    msclkid: msclkid,
102    landingReferrer: landingReferrer,
103    utmSource: utmSource,
104    utmMedium: utmMedium,
105    utmCampaign: utmCampaign,
106    utmContent: utmContent,
107    utmTerm: utmTerm,
108    landingPage: landingPage,
109    referrer: referrer,
110    device: device,
111    channel: channel,
112  };
113}
114
115// ── Send to Google Sheet ──
116function sendToSheet(payload) {
117  if (!GOOGLE_SCRIPT_URL) return;
118  fetch(GOOGLE_SCRIPT_URL, {
119    method: "POST",
120    mode: "no-cors",
121    headers: { "Content-Type": "text/plain" },
122    body: JSON.stringify(payload),
123  }).catch(function (err) {
124    console.error("[Sheet]", err);
125  });
126}
127
128// ── Send to CRM API (fan-out to every enabled platform) ──
129// Per-platform accountId/shopLocationId are injected here, so callers must
130// NOT put them in `payload`. Each platform fires independently.
131function sendToCRM(method, path, payload) {
132  getEnabledPlatforms().forEach(function (platform) {
133    var body = Object.assign({}, payload, {
134      accountId: platform.accountId,
135      shopLocationId: platform.shopLocationId,
136    });
137    fetch(platform.apiUrl + path, {
138      method: method,
139      headers: {
140        "Content-Type": "application/json",
141        "X-Atlas-Api-Key": platform.apiKey,
142      },
143      body: JSON.stringify(body),
144    }).catch(function (err) {
145      console.error("[CRM " + platform.name + "]", err);
146    });
147  });
148}
149
150// ── Upload photo to S3 via every enabled platform ──
151// Each backend stores to its own S3 bucket and returns its own URL. We
152// resolve to a { platformName: url } map so the later PATCH /document
153// call can send each platform its own URL. A failed platform is simply
154// absent from the map.
155function uploadPhotoToCRM(file) {
156  if (!file) return Promise.resolve({});
157  var platforms = getEnabledPlatforms();
158  if (platforms.length === 0) return Promise.resolve({});
159
160  return Promise.allSettled(
161    platforms.map(function (platform) {
162      var formData = new FormData();
163      formData.append("file", file);
164      return fetch(platform.apiUrl + "/api/v1/web-leads/upload", {
165        method: "POST",
166        headers: { "X-Atlas-Api-Key": platform.apiKey },
167        body: formData,
168      })
169        .then(function (r) {
170          return r.json();
171        })
172        .then(function (res) {
173          return {
174            platform: platform.name,
175            url: res && res.data && res.data.url ? res.data.url : null,
176          };
177        });
178    }),
179  ).then(function (results) {
180    var urlMap = {};
181    results.forEach(function (result, i) {
182      var platformName = platforms[i].name;
183      if (result.status === "fulfilled" && result.value && result.value.url) {
184        urlMap[platformName] = result.value.url;
185      } else if (result.status === "rejected") {
186        console.error("[Photo Upload " + platformName + "]", result.reason);
187      }
188    });
189    return urlMap;
190  });
191}
192
193function handleStep2ScanPhoto(input) {
194  var file = input.files && input.files[0];
195  if (!file) return;
196  var platforms = getEnabledPlatforms();
197  if (platforms.length === 0) return;
198
199  var btn = document.querySelector('[onclick*="step2ScanInput"]');
200  if (btn)
201    btn.innerHTML =
202      '<span class="material-symbols-outlined text-blue-400" style="font-size:22px;">hourglass_top</span>';
203
204  uploadPhotoToCRM(file)
205    .then(function (urlMap) {
206      var gotAny = Object.keys(urlMap).length > 0;
207      if (gotAny) {
208        formState.photoUrls = urlMap;
209        if (btn)
210          btn.innerHTML =
211            '<span class="material-symbols-outlined text-green-500" style="font-size:22px;">check_circle</span>';
212      } else if (btn) {
213        btn.innerHTML =
214          '<span class="material-symbols-outlined text-gray-400" style="font-size:22px;">photo_camera</span>';
215      }
216    })
217    .catch(function (err) {
218      console.error("[Step2 Photo Upload]", err);
219      if (btn)
220        btn.innerHTML =
221          '<span class="material-symbols-outlined text-gray-400" style="font-size:22px;">photo_camera</span>';
222    });
223}
224
225// ═══════════ SUBMIT: New Lead ═══════════
226function submitLeadData() {
227  currentLeadId = generateLeadId();
228  var tracking = getTrackingParams();
229  var vehicle = formState.year + " " + formState.make + " " + formState.model;
230
231  // Capture zip code from header input or URL parameter
232  var zipInput = document.getElementById("headerZipCode");
233  var zipCode = zipInput ? zipInput.value.trim() : "";
234  if (!zipCode) {
235    try {
236      zipCode = new URL(window.location.href).searchParams.get("zip") || "";
237    } catch (e) {}
238  }
239
240  // Build combined damage label from all selections
241  var allDamageLabels =
242    formState.damages
243      .map(function (d) {
244        return d.label;
245      })
246      .join(", ") || formState.damageLabel;
247  var allDamageTypes =
248    formState.damages
249      .map(function (d) {
250        return d.type;
251      })
252      .join(", ") || formState.damage;
253
254  // → Google Sheet
255  sendToSheet({
256    action: "new",
257    leadId: currentLeadId,
258    name: formState.name,
259    phone: formState.phone,
260    vehicle: vehicle,
261    damageType: allDamageLabels,
262    insurance: formState.insurance,
263    zipCode: zipCode,
264    sourcePage: window.location.pathname,
265    gclid: tracking.gclid,
266    utmSource: tracking.utmSource,
267    utmMedium: tracking.utmMedium,
268    utmCampaign: tracking.utmCampaign,
269    utmContent: tracking.utmContent,
270    utmTerm: tracking.utmTerm,
271    landingPage: tracking.landingPage,
272    device: tracking.device,
273    channel: tracking.channel,
274  });
275
276  // → CRM (Atlas Backend — fans out to every enabled platform).
277  // accountId/shopLocationId are injected per-platform inside sendToCRM.
278  // photoUrl is per-platform: sendToCRM overwrites below for each platform.
279  getEnabledPlatforms().forEach(function (platform) {
280    var body = {
281      accountId: platform.accountId,
282      shopLocationId: platform.shopLocationId,
283      leadId: currentLeadId,
284      name: formState.name,
285      phone: formState.phone,
286      year: formState.year,
287      make: formState.make,
288      model: formState.model,
289      bodyType: formState.bodyType || null,
290      damageType: allDamageTypes,
291      damageLabel: allDamageLabels,
292      damages: formState.damages,
293      insurance: formState.insurance,
294      vin: formState.vin || null,
295      plate: formState.plate || null,
296      zipCode: zipCode,
297      source: "quote-form",
298      pageUrl: window.location.href,
299      gclid: tracking.gclid,
300      wbraid: tracking.wbraid,
301      gbraid: tracking.gbraid,
302      fbclid: tracking.fbclid,
303      msclkid: tracking.msclkid,
304      utmSource: tracking.utmSource,
305      utmMedium: tracking.utmMedium,
306      utmCampaign: tracking.utmCampaign,
307      utmContent: tracking.utmContent,
308      utmTerm: tracking.utmTerm,
309      landingPage: tracking.landingPage,
310      referrer: tracking.landingReferrer,
311      device: tracking.device,
312      photoUrl: formState.photoUrls[platform.name] || null,
313    };
314    fetch(platform.apiUrl + "/api/v1/web-leads", {
315      method: "POST",
316      headers: {
317        "Content-Type": "application/json",
318        "X-Atlas-Api-Key": platform.apiKey,
319      },
320      body: JSON.stringify(body),
321    }).catch(function (err) {
322      console.error("[CRM " + platform.name + "]", err);
323    });
324  });
325
326  // ── Fire conversion events ──
327  // Google Ads conversion
328  if (typeof gtag === "function") {
329    gtag("event", "conversion", {
330      send_to: "AW-1037433427/quote_submitted",
331      value: 1.0,
332      currency: "USD",
333    });
334    // GA4 custom event
335    gtag("event", "quote_form_submitted", {
336      event_category: "conversion",
337      event_label: allDamageLabels,
338      vehicle: vehicle,
339      lead_id: currentLeadId,
340    });
341  }
342  // Bing UET conversion
343  window.uetq = window.uetq || [];
344  window.uetq.push("event", "quote_submitted", {});
345
346  console.log("Lead submitted:", currentLeadId);
347}
348
349// ═══════════ SUBMIT: Lead Update (extras) ═══════════
350function submitLeadUpdate(extras) {
351  if (!currentLeadId) return;
352
353  // → Google Sheet (flags only — no binary data)
354  sendToSheet({
355    action: "update",
356    leadId: currentLeadId,
357    vin: extras.vin || null,
358    plate: extras.plate || null,
359    hasPhoto: extras.hasPhoto || false,
360    insuranceProvider: extras.insuranceProvider || null,
361  });
362
363  // → Photo to S3 (per-platform upload, then attach to each platform's
364  //   own web-lead with its own returned URL). Each platform is independent.
365  if (extras.photoFile) {
366    uploadPhotoToCRM(extras.photoFile).then(function (urlMap) {
367      if (!currentLeadId) return;
368      // Merge so future reads of formState.photoUrls see the uploads too.
369      formState.photoUrls = Object.assign({}, formState.photoUrls, urlMap);
370      getEnabledPlatforms().forEach(function (platform) {
371        var url = urlMap[platform.name];
372        if (!url) return;
373        fetch(
374          platform.apiUrl +
375            "/api/v1/web-leads/" +
376            currentLeadId +
377            "/document",
378          {
379            method: "PATCH",
380            headers: {
381              "Content-Type": "application/json",
382              "X-Atlas-Api-Key": platform.apiKey,
383            },
384            body: JSON.stringify({ photoUrl: url }),
385          },
386        ).catch(function (err) {
387          console.error("[Attach Document " + platform.name + "]", err);
388        });
389      });
390    });
391  }
392
393  console.log("Lead updated:", currentLeadId, Object.keys(extras));
394}
395
396// ═══════════════════════════════════════════
397// STEP NAVIGATION
398// ═══════════════════════════════════════════
399function goToStep(n) {
400  document.querySelectorAll(".quote-step").forEach(function (el) {
401    el.classList.remove("active");
402  });
403  var stepId;
404  if (n === "confirm") stepId = "stepConfirm";
405  else if (n === "1b") stepId = "step1b";
406  else stepId = "step" + n;
407  var stepEl = document.getElementById(stepId);
408  if (stepEl) {
409    stepEl.classList.remove("active");
410    // Small delay for animation reset
411    setTimeout(function () {
412      stepEl.classList.add("active");
413    }, 10);
414  }
415  window.scrollTo({ top: 0, behavior: "smooth" });
416}
417
418// ═══════════════════════════════════════════
419// STEP 1: Damage selection
420// ═══════════════════════════════════════════
421var sideWindowLabels = {
422  "driver-front": "Front Driver Door Glass",
423  "passenger-front": "Front Passenger Door Glass",
424  "driver-rear": "Rear Driver Door Glass",
425  "passenger-rear": "Rear Passenger Door Glass",
426  "other-side": "Other Side Glass",
427};
428
429function selectDamage(el) {
430  // Deselect all
431  document.querySelectorAll(".damage-card").forEach(function (c) {
432    c.classList.remove("selected");
433  });
434  // Select this one
435  el.classList.add("selected");
436  var damageType = el.getAttribute("data-damage");
437  var damageLabel = el.querySelector(".damage-label").textContent;
438
439  // Keep legacy single fields for backward compat
440  formState.damage = damageType;
441  formState.damageLabel = damageLabel;
442
443  // Side window → go to sub-picker
444  if (damageType === "side-window") {
445    setTimeout(function () {
446      // Reset side window selections
447      formState.sideWindows = [];
448      document.querySelectorAll(".side-card").forEach(function (c) {
449        c.classList.remove("selected");
450      });
451      document.getElementById("step1bNext").disabled = true;
452      goToStep("1b");
453    }, 300);
454    return;
455  }
456
457  // All other types → add to damages array and go to step 2
458  setTimeout(function () {
459    // Check if this damage type already exists
460    var exists = formState.damages.some(function (d) {
461      return d.type === damageType;
462    });
463    if (!exists) {
464      formState.damages.push({ type: damageType, label: damageLabel });
465    }
466    renderDamagePills();
467    goToStep(2);
468  }, 300);
469}
470
471function toggleSideWindow(el) {
472  el.classList.toggle("selected");
473  var selected = document.querySelectorAll(".side-card.selected");
474  document.getElementById("step1bNext").disabled = selected.length === 0;
475}
476
477function confirmSideWindows() {
478  formState.sideWindows = [];
479  document.querySelectorAll(".side-card.selected").forEach(function (el) {
480    var sideType = el.getAttribute("data-side");
481    formState.sideWindows.push(sideType);
482  });
483
484  // Add each selected side window as a separate damage entry
485  formState.sideWindows.forEach(function (sw) {
486    var label = sideWindowLabels[sw] || sw;
487    var exists = formState.damages.some(function (d) {
488      return d.type === "side-" + sw;
489    });
490    if (!exists) {
491      formState.damages.push({ type: "side-" + sw, label: label });
492    }
493  });
494
495  // Update legacy fields
496  if (formState.damages.length > 0) {
497    formState.damage = formState.damages[0].type;
498    formState.damageLabel = formState.damages
499      .map(function (d) {
500        return d.label;
501      })
502      .join(", ");
503  }
504
505  renderDamagePills();
506  goToStep(2);
507}
508
509function renderDamagePills() {
510  var container = document.getElementById("damagePillsContainer");
511  if (!container) return;
512  var html = "";
513  formState.damages.forEach(function (d, i) {
514    html += '<div class="damage-pill">';
515    html +=
516      '<span class="material-symbols-outlined" style="font-size: 14px; font-variation-settings: \'FILL\' 1;">check_circle</span>';
517    html += d.label;
518    html +=
519      '<span class="pill-x" onclick="removeDamage(' + i + ')">&times;</span>';
520    html += "</div>";
521  });
522  html += '<button class="add-damage-btn" onclick="addMoreDamage()">';
523  html +=
524    '<span class="material-symbols-outlined" style="font-size: 14px;">add</span> Add';
525  html += "</button>";
526  container.innerHTML = html;
527}
528
529function removeDamage(index) {
530  formState.damages.splice(index, 1);
531  // Update legacy fields
532  if (formState.damages.length > 0) {
533    formState.damage = formState.damages[0].type;
534    formState.damageLabel = formState.damages
535      .map(function (d) {
536        return d.label;
537      })
538      .join(", ");
539  } else {
540    formState.damage = "";
541    formState.damageLabel = "";
542  }
543  renderDamagePills();
544  // If no damages left, go back to step 1
545  if (formState.damages.length === 0) {
546    goToStep(1);
547  }
548}
549
550function addMoreDamage() {
551  // Go back to step 1 to add another damage type
552  // Deselect all cards so it feels fresh
553  document.querySelectorAll(".damage-card").forEach(function (c) {
554    c.classList.remove("selected");
555  });
556  goToStep(1);
557}
558
559function goBackFromStep2() {
560  // Clear damages and go back to step 1
561  formState.damages = [];
562  formState.sideWindows = [];
563  formState.damage = "";
564  formState.damageLabel = "";
565  goToStep(1);
566}
567
568// ═══════════════════════════════════════════
569// STEP 2: Vehicle selection (year-aware)
570// ═══════════════════════════════════════════
571// vehicleDB is loaded from /vehicles.json — structured as:
572// { makeKey: { name: "Make", models: { modelKey: { name, years: [...], ... } } } }
573var vehicleDB = null;
574// Flat fallback used only if vehicles.json fails to load
575var vehicleDataFallback = {
576  Acura: { ILX: true, TLX: true, Integra: true, MDX: true, RDX: true },
577  "Alfa Romeo": { Giulia: true, Stelvio: true, Tonale: true },
578  Audi: {
579    A3: true,
580    A4: true,
581    A5: true,
582    A6: true,
583    A7: true,
584    Q3: true,
585    Q5: true,
586    Q7: true,
587    Q8: true,
588    "e-tron": true,
589  },
590  BMW: {
591    "2 Series": true,
592    "3 Series": true,
593    "4 Series": true,
594    "5 Series": true,
595    "7 Series": true,
596    X1: true,
597    X3: true,
598    X5: true,
599    X7: true,
600    iX: true,
601    i4: true,
602  },
603  Buick: {
604    Enclave: true,
605    Encore: true,
606    "Encore GX": true,
607    Envision: true,
608    Envista: true,
609  },
610  Cadillac: {
611    CT4: true,
612    CT5: true,
613    Escalade: true,
614    XT4: true,
615    XT5: true,
616    XT6: true,
617    Lyriq: true,
618  },
619  Chevrolet: {
620    Blazer: true,
621    Camaro: true,
622    Colorado: true,
623    Corvette: true,
624    Equinox: true,
625    Malibu: true,
626    "Silverado 1500": true,
627    "Silverado 2500HD": true,
628    "Silverado 3500HD": true,
629    Suburban: true,
630    Tahoe: true,
631    Trailblazer: true,
632    Traverse: true,
633    Trax: true,
634  },
635  Chrysler: { 300: true, Pacifica: true },
636  Dodge: { Challenger: true, Charger: true, Durango: true, Hornet: true },
637  Fiat: { "500X": true, "500e": true },
638  Ford: {
639    Bronco: true,
640    "Bronco Sport": true,
641    Edge: true,
642    Escape: true,
643    Expedition: true,
644    Explorer: true,
645    "F-150": true,
646    "F-150 Lightning": true,
647    "F-250": true,
648    "F-350": true,
649    Maverick: true,
650    Mustang: true,
651    "Mustang Mach-E": true,
652    Ranger: true,
653    Transit: true,
654  },
655  Genesis: {
656    G70: true,
657    G80: true,
658    G90: true,
659    GV60: true,
660    GV70: true,
661    GV80: true,
662  },
663  GMC: {
664    Acadia: true,
665    Canyon: true,
666    "Hummer EV": true,
667    "Sierra 1500": true,
668    "Sierra 2500HD": true,
669    Terrain: true,
670    Yukon: true,
671    "Yukon XL": true,
672  },
673  Honda: {
674    Accord: true,
675    Civic: true,
676    "CR-V": true,
677    "HR-V": true,
678    Odyssey: true,
679    Passport: true,
680    Pilot: true,
681    Ridgeline: true,
682    Prologue: true,
683  },
684  Hyundai: {
685    Elantra: true,
686    "Ioniq 5": true,
687    "Ioniq 6": true,
688    Kona: true,
689    Palisade: true,
690    "Santa Cruz": true,
691    "Santa Fe": true,
692    Sonata: true,
693    Tucson: true,
694    Venue: true,
695  },
696  Infiniti: {
697    Q50: true,
698    Q60: true,
699    QX50: true,
700    QX55: true,
701    QX60: true,
702    QX80: true,
703  },
704  Jaguar: { "E-PACE": true, "F-PACE": true, "F-TYPE": true, "I-PACE": true },
705  Jeep: {
706    Cherokee: true,
707    Compass: true,
708    Gladiator: true,
709    "Grand Cherokee": true,
710    "Grand Cherokee L": true,
711    "Grand Wagoneer": true,
712    Renegade: true,
713    Wagoneer: true,
714    Wrangler: true,
715  },
716  Kia: {
717    Carnival: true,
718    EV6: true,
719    EV9: true,
720    Forte: true,
721    K5: true,
722    Niro: true,
723    Seltos: true,
724    Sorento: true,
725    Soul: true,
726    Sportage: true,
727    Stinger: true,
728    Telluride: true,
729  },
730  "Land Rover": {
731    Defender: true,
732    Discovery: true,
733    "Discovery Sport": true,
734    "Range Rover": true,
735    "Range Rover Evoque": true,
736    "Range Rover Sport": true,
737    "Range Rover Velar": true,
738  },
739  Lexus: {
740    ES: true,
741    GX: true,
742    IS: true,
743    LX: true,
744    NX: true,
745    RX: true,
746    RZ: true,
747    TX: true,
748    UX: true,
749  },
750  Lincoln: { Aviator: true, Corsair: true, Nautilus: true, Navigator: true },
751  Mazda: {
752    "CX-30": true,
753    "CX-5": true,
754    "CX-50": true,
755    "CX-70": true,
756    "CX-90": true,
757    Mazda3: true,
758    "MX-5 Miata": true,
759  },
760  "Mercedes-Benz": {
761    "A-Class": true,
762    "C-Class": true,
763    CLA: true,
764    "E-Class": true,
765    EQB: true,
766    EQE: true,
767    EQS: true,
768    GLA: true,
769    GLB: true,
770    GLC: true,
771    GLE: true,
772    GLS: true,
773    "S-Class": true,
774    Sprinter: true,
775  },
776  Mini: { Clubman: true, Countryman: true, Hardtop: true },
777  Mitsubishi: {
778    "Eclipse Cross": true,
779    Mirage: true,
780    Outlander: true,
781    "Outlander Sport": true,
782  },
783  Nissan: {
784    Altima: true,
785    Ariya: true,
786    Frontier: true,
787    Kicks: true,
788    Leaf: true,
789    Maxima: true,
790    Murano: true,
791    Pathfinder: true,
792    Rogue: true,
793    Sentra: true,
794    Titan: true,
795    Versa: true,
796    Z: true,
797  },
798  Porsche: {
799    911: true,
800    Cayenne: true,
801    Macan: true,
802    Panamera: true,
803    Taycan: true,
804  },
805  Ram: { 1500: true, 2500: true, 3500: true, ProMaster: true },
806  Rivian: { R1S: true, R1T: true },
807  Subaru: {
808    Ascent: true,
809    Crosstrek: true,
810    Forester: true,
811    Impreza: true,
812    Legacy: true,
813    Outback: true,
814    Solterra: true,
815    WRX: true,
816  },
817  Tesla: {
818    "Model 3": true,
819    "Model S": true,
820    "Model X": true,
821    "Model Y": true,
822    Cybertruck: true,
823  },
824  Toyota: {
825    "4Runner": true,
826    bZ4X: true,
827    Camry: true,
828    Corolla: true,
829    "Corolla Cross": true,
830    Crown: true,
831    GR86: true,
832    "Grand Highlander": true,
833    Highlander: true,
834    "Land Cruiser": true,
835    Prius: true,
836    RAV4: true,
837    Sequoia: true,
838    Sienna: true,
839    Supra: true,
840    Tacoma: true,
841    Tundra: true,
842    Venza: true,
843  },
844  Volkswagen: {
845    Atlas: true,
846    "Atlas Cross Sport": true,
847    "Golf GTI": true,
848    "Golf R": true,
849    "ID.4": true,
850    Jetta: true,
851    Taos: true,
852    Tiguan: true,
853  },
854  Volvo: {
855    C40: true,
856    S60: true,
857    S90: true,
858    XC40: true,
859    XC60: true,
860    XC90: true,
861    EX30: true,
862    EX90: true,
863  },
864  Other: { "Other / Not Listed": true },
865};
866
867// Load vehicles.json for year-aware filtering
868// Motorcycle/ATV models to exclude (not relevant for auto glass)
869var _motoFilter = {
870  bmw: [
871    "C 4",
872    "C 6",
873    "C E",
874    "CE ",
875    "F 6",
876    "F 7",
877    "F 8",
878    "F 9",
879    "G 3",
880    "G 4",
881    "G 6",
882    "HP",
883    "K 1",
884    "M 1000",
885    "R 1",
886    "R 8",
887    "R 9",
888    "R 12",
889    "R 18",
890    "R n",
891    "S 1000",
892  ],
893  honda: {
894    exclude: true,
895    keep: [
896      "Accord",
897      "Civic",
898      "CR-V",
899      "CR-Z",
900      "Clarity",
901      "Crosstour",
902      "Element",
903      "Fit",
904      "HR-V",
905      "Insight",
906      "Odyssey",
907      "Passport",
908      "Pilot",
909      "Prelude",
910      "Prologue",
911      "Ridgeline",
912      "S2000",
913      "del Sol",
914      "ZR-V",
915    ],
916  },
917  suzuki: {
918    exclude: true,
919    keep: [
920      "Aerio",
921      "Equator",
922      "Esteem",
923      "Forenza",
924      "Grand Vitara",
925      "Kizashi",
926      "Reno",
927      "SX4",
928      "Swift",
929      "Verona",
930      "XL",
931      "Vitara",
932      "Samurai",
933      "Sidekick",
934      "X-90",
935    ],
936  },
937};
938function _filterMotorcycles(db) {
939  if (!db) return db;
940  Object.keys(_motoFilter).forEach(function (makeKey) {
941    if (!db[makeKey] || !db[makeKey].models) return;
942    var rule = _motoFilter[makeKey];
943    var models = db[makeKey].models;
944    Object.keys(models).forEach(function (mk) {
945      var name = models[mk].name || mk;
946      if (rule.exclude) {
947        // Keep only whitelisted car models
948        var isKeep = rule.keep.some(function (p) {
949          return name.indexOf(p) === 0;
950        });
951        if (!isKeep) delete models[mk];
952      } else {
953        // Remove models matching motorcycle prefixes
954        var isMoto = rule.some(function (p) {
955          return name.indexOf(p) === 0;
956        });
957        if (isMoto) delete models[mk];
958      }
959    });
960  });
961  return db;
962}
963(function loadVehicleDB() {
964  fetch("/vehicles.json")
965    .then(function (r) {
966      return r.json();
967    })
968    .then(function (json) {
969      vehicleDB = _filterMotorcycles(json);
970    })
971    .catch(function () {
972      vehicleDB = null; /* fallback to flat data */
973    });
974})();
975
976// Get makes that have at least one model available for the given year
977function getMakesForYear(year) {
978  if (!vehicleDB) {
979    // Fallback: return all makes from flat data
980    return Object.keys(vehicleDataFallback).sort();
981  }
982  var yr = parseInt(year, 10);
983  var makes = {};
984  var foundAny = false;
985  Object.keys(vehicleDB).forEach(function (makeKey) {
986    var makeObj = vehicleDB[makeKey];
987    var hasModelForYear = Object.keys(makeObj.models).some(function (modelKey) {
988      return makeObj.models[modelKey].years.indexOf(yr) !== -1;
989    });
990    if (hasModelForYear) {
991      makes[makeObj.name] = true;
992      foundAny = true;
993    }
994  });
995  // If year is outside data range (e.g. pre-2010), show ALL makes as fallback
996  if (!foundAny) {
997    return Object.keys(vehicleDataFallback).sort();
998  }
999  // Always include "Other"
1000  makes["Other"] = true;
1001  return Object.keys(makes).sort();
1002}
1003
1004// Get models for a given make + year combination
1005function getModelsForMakeYear(makeName, year) {
1006  if (!vehicleDB) {
1007    // Fallback: return all models for this make
1008    if (vehicleDataFallback[makeName]) {
1009      return Object.keys(vehicleDataFallback[makeName]).sort();
1010    }
1011    return [];
1012  }
1013  if (makeName === "Other") {
1014    return ["Other / Not Listed"];
1015  }
1016  var yr = parseInt(year, 10);
1017  var models = [];
1018  Object.keys(vehicleDB).forEach(function (makeKey) {
1019    if (vehicleDB[makeKey].name === makeName) {
1020      Object.keys(vehicleDB[makeKey].models).forEach(function (modelKey) {
1021        var modelObj = vehicleDB[makeKey].models[modelKey];
1022        if (modelObj.years.indexOf(yr) !== -1) {
1023          models.push(modelObj.name);
1024        }
1025      });
1026    }
1027  });
1028  // If no models found for this year (older vehicle or make not in DB), show all models
1029  if (models.length === 0 && vehicleDataFallback[makeName]) {
1030    return Object.keys(vehicleDataFallback[makeName]).sort();
1031  }
1032  return models.sort();
1033}
1034
1035// ═══════════════════════════════════════════
1036// NHTSA VIN DECODER (shared by Step 2 + confirmation)
1037// ═══════════════════════════════════════════
1038var vinDecodeCache = {};
1039
1040async function decodeVIN(vin) {
1041  vin = vin.toUpperCase().trim();
1042  if (vinDecodeCache[vin]) return vinDecodeCache[vin];
1043  var res = await fetch(
1044    "https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/" +
1045      vin +
1046      "?format=json",
1047  );
1048  var data = await res.json();
1049  var r = data.Results && data.Results[0];
1050  if (!r || !r.Make || r.Make === "" || !r.Model || r.Model === "")
1051    throw new Error("Invalid VIN");
1052  var result = {
1053    year: r.ModelYear || "",
1054    make: r.Make || "",
1055    model: r.Model || "",
1056    trim: r.Trim || "",
1057    bodyClass: r.BodyClass || "",
1058    vin: vin,
1059  };
1060  vinDecodeCache[vin] = result;
1061  return result;
1062}
1063
1064// Helper: Set a select dropdown value, adding the option if it doesn't exist
1065function setSelectValue(selectId, value) {
1066  var sel = document.getElementById(selectId);
1067  if (!sel) return;
1068  // Check if option already exists
1069  for (var i = 0; i < sel.options.length; i++) {
1070    if (sel.options[i].value.toLowerCase() === value.toLowerCase()) {
1071      sel.value = sel.options[i].value;
1072      return;
1073    }
1074  }
1075  // Not found — add it
1076  var opt = document.createElement("option");
1077  opt.value = value;
1078  opt.textContent = value;
1079  sel.appendChild(opt);
1080  sel.value = value;
1081}
1082
1083// ── Step 2: VIN input handler with NHTSA decode ──
1084var step2DecodeTimer = null;
1085function handleStep2Vin(input) {
1086  input.value = input.value.replace(/[^A-Z0-9]/gi, "").toUpperCase();
1087  var val = input.value;
1088  var len = val.length;
1089  var badge = document.getElementById("step2VinBadge");
1090  var badgeInner = document.getElementById("step2VinBadgeInner");
1091  var badgeIcon = document.getElementById("step2VinIcon");
1092  var badgeText = document.getElementById("step2VinText");
1093
1094  if (step2DecodeTimer) {
1095    clearTimeout(step2DecodeTimer);
1096    step2DecodeTimer = null;
1097  }
1098
1099  if (len === 17) {
1100    // Show decoding state
1101    badge.classList.remove("hidden");
1102    badgeInner.style.background = "#fffbeb";
1103    badgeInner.style.color = "#b45309";
1104    badgeInner.style.border = "1px solid #fde68a";
1105    badgeIcon.textContent = "hourglass_top";
1106    badgeText.textContent = "Decoding VIN...";
1107
1108    step2DecodeTimer = setTimeout(function () {
1109      decodeVIN(val)
1110        .then(function (v) {
1111          // Success: auto-fill Year, Make, Model
1112          // Set Year
1113          var yearSel = document.getElementById("selectYear");
1114          yearSel.value = v.year;
1115          onYearChange();
1116
1117          // Set Make (case-insensitive match or add)
1118          var makeSel = document.getElementById("selectMake");
1119          makeSel.disabled = false;
1120          var makeMatch = "";
1121          // Check options populated by onYearChange
1122          for (var mi = 0; mi < makeSel.options.length; mi++) {
1123            if (
1124              makeSel.options[mi].value.toLowerCase() === v.make.toLowerCase()
1125            ) {
1126              makeMatch = makeSel.options[mi].value;
1127              break;
1128            }
1129          }
1130          if (makeMatch) {
1131            makeSel.value = makeMatch;
1132          } else {
1133            setSelectValue("selectMake", v.make);
1134          }
1135          onMakeChange();
1136
1137          // Set Model (case-insensitive match or add)
1138          var modelSel = document.getElementById("selectModel");
1139          modelSel.disabled = false;
1140          var modelMatch = "";
1141          // Check options populated by onMakeChange
1142          for (var mj = 0; mj < modelSel.options.length; mj++) {
1143            if (
1144              modelSel.options[mj].value.toLowerCase() === v.model.toLowerCase()
1145            ) {
1146              modelMatch = modelSel.options[mj].value;
1147              break;
1148            }
1149          }
1150          if (modelMatch) {
1151            modelSel.value = modelMatch;
1152          } else {
1153            setSelectValue("selectModel", v.model);
1154          }
1155          onModelChange();
1156
1157          // Store VIN in formState
1158          formState.vin = val;
1159
1160          // Success badge
1161          badge.classList.remove("hidden");
1162          badgeInner.style.background = "#f0faf4";
1163          badgeInner.style.color = "#006d33";
1164          badgeInner.style.border = "1px solid #bbf0d0";
1165          badgeIcon.textContent = "check_circle";
1166          var label = v.year + " " + v.make + " " + v.model;
1167          if (v.trim) label += " " + v.trim;
1168          badgeText.textContent = "VIN decoded — " + label;
1169        })
1170        .catch(function (err) {
1171          badge.classList.remove("hidden");
1172          badgeInner.style.background = "#fef2f2";
1173          badgeInner.style.color = "#dc2626";
1174          badgeInner.style.border = "1px solid #fecaca";
1175          badgeIcon.textContent = "error";
1176          badgeText.textContent =
1177            "Could not decode VIN — please select manually";
1178        });
1179    }, 300);
1180  } else if (len === 7) {
1181    badge.classList.remove("hidden");
1182    badgeInner.style.background = "#f0f4ff";
1183    badgeInner.style.color = "#007AFF";
1184    badgeInner.style.border = "1px solid #bfdbfe";
1185    badgeIcon.textContent = "directions_car";
1186    badgeText.textContent = "TX plate detected — we'll look up your vehicle";
1187    formState.plate = val;
1188  } else if (len > 7 && len < 17) {
1189    badge.classList.remove("hidden");
1190    badgeInner.style.background = "#f9fafb";
1191    badgeInner.style.color = "#9ca3af";
1192    badgeInner.style.border = "1px solid #e5e7eb";
1193    badgeIcon.textContent = "edit";
1194    badgeText.textContent = 17 - len + " more characters for VIN";
1195  } else if (len > 0 && len < 7) {
1196    badge.classList.remove("hidden");
1197    badgeInner.style.background = "#f9fafb";
1198    badgeInner.style.color = "#9ca3af";
1199    badgeInner.style.border = "1px solid #e5e7eb";
1200    badgeIcon.textContent = "edit";
1201    badgeText.textContent = "Type 7 for plate or 17 for VIN";
1202  } else {
1203    badge.classList.add("hidden");
1204  }
1205}

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.