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 + ')">×</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.