# Shop by Model

:::raw
<!-- ═══════════════════════════════════════════════════════════════════════
     Shop By Model — self-contained widget (paste block)
     ─────────────────────────────────────────────────────────────────────
     Paste this ENTIRE block into a Savvy CMS page body (Custom Html).
     Everything is namespaced under .sbm so it won't touch the dealer theme.

     Data source: a daily-cached per-rooftop JSON feed served from the VPS at
       https://vcp.savvydealer.com/{rooftop}.json
     (built nightly from the Savvy Incentive API — new inventory only).

     To configure a rooftop, edit ONLY the SBM_CONFIG object below.
     Model "View Inventory" links go to vcpBase + the feed's per-model slug,
     e.g.  /vcp/new/ford/f-150  (the Savvy platform's canonical SRP path).
═══════════════════════════════════════════════════════════════════════ -->

<style>
.sbm *, .sbm *::before, .sbm *::after { box-sizing: border-box; margin: 0; padding: 0; }
.sbm {
  --sbm-primary: #003478;
  --sbm-accent: #d32f2f;
  --sbm-accent-hover: #b71c1c;
  --sbm-text: #1a1a2e;
  --sbm-text-lt: #555;
  --sbm-text-mt: #888;
  --sbm-bg: #f5f6fa;
  --sbm-card: #fff;
  --sbm-border: #e0e3eb;
  --sbm-radius: 10px;
  --sbm-font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  font-family: var(--sbm-font);
  color: var(--sbm-text);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  max-width: 1320px;
  margin: 0 auto;
  padding: 0 16px;
}

/* Hero */
.sbm-hero { padding: 28px 0 8px; }
.sbm-hero h1 { font-size: 26px; font-weight: 700; letter-spacing: -0.3px; color: var(--sbm-text); }
.sbm-hero p { font-size: 15px; color: var(--sbm-text-lt); margin-top: 4px; }

/* Controls bar */
.sbm-bar {
  display: flex; align-items: center; justify-content: space-between;
  flex-wrap: wrap; gap: 12px; padding: 14px 0 8px;
  border-bottom: 1px solid var(--sbm-border); margin-bottom: 4px;
}
.sbm-results { font-size: 15px; color: var(--sbm-text-lt); font-weight: 500; }
.sbm-results strong { color: var(--sbm-text); font-weight: 700; }
.sbm-fresh { font-size: 12px; color: var(--sbm-text-mt); margin-top: 2px; }
.sbm-fresh span { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #2e9e5b; margin-right: 5px; vertical-align: middle; }
.sbm-sort { display: flex; align-items: center; gap: 8px; }
.sbm-sort label { font-size: 13px; color: var(--sbm-text-mt); }
.sbm-sort select {
  font-family: var(--sbm-font); font-size: 13px; color: var(--sbm-text);
  padding: 8px 30px 8px 12px; border: 1px solid var(--sbm-border); border-radius: 8px;
  background: #fff url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='%23888'><path d='M7 10l5 5 5-5z'/></svg>") no-repeat right 10px center;
  -webkit-appearance: none; appearance: none; cursor: pointer;
}

/* Grid */
.sbm-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; padding: 16px 0 48px; }

/* Card */
.sbm-card {
  background: var(--sbm-card); border-radius: var(--sbm-radius); overflow: hidden;
  box-shadow: 0 2px 12px rgba(0,0,0,.08);
  transition: transform .25s ease, box-shadow .25s ease;
  cursor: pointer; text-decoration: none; color: inherit;
  display: flex; flex-direction: column;
}
.sbm-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,.14); }

.sbm-card-img { position: relative; aspect-ratio: 16/10; background: linear-gradient(135deg, #f0f2f5, #e8eaf0); overflow: hidden; }
.sbm-card-img img { width: 100%; height: 100%; object-fit: cover; transition: transform .3s ease; }
.sbm-card:hover .sbm-card-img img { transform: scale(1.03); }
.sbm-card-img .sbm-placeholder { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); width: 80px; height: 80px; opacity: .18; }

.sbm-badge {
  position: absolute; top: 12px; left: 12px;
  background: rgba(0,20,50,.82); backdrop-filter: blur(6px);
  color: #fff; padding: 8px 14px; border-radius: 8px; text-align: center; line-height: 1.15;
}
.sbm-badge-num { display: block; font-size: 28px; font-weight: 800; letter-spacing: -1px; }
.sbm-badge-lbl { display: block; font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: .5px; opacity: .85; }

/* Status chip (Hot / EV / Low) */
.sbm-chip {
  position: absolute; top: 12px; right: 12px;
  font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .4px;
  padding: 5px 9px; border-radius: 6px; color: #fff; backdrop-filter: blur(6px);
}
.sbm-chip.hot { background: rgba(211,47,47,.92); }
.sbm-chip.ev  { background: rgba(21,128,61,.92); }
.sbm-chip.low { background: rgba(120,80,0,.85); }

.sbm-card-body { padding: 16px 18px 18px; flex: 1; display: flex; flex-direction: column; gap: 6px; }
.sbm-model { font-size: 17px; font-weight: 700; letter-spacing: -0.2px; }
.sbm-make { color: var(--sbm-text-mt); font-weight: 400; }
.sbm-price { font-size: 15px; color: var(--sbm-text-lt); }
.sbm-price strong { color: var(--sbm-text); font-weight: 700; }
.sbm-disc { margin-top: auto; padding-top: 8px; }
.sbm-disc button { background: none; border: none; color: var(--sbm-primary); font-size: 12px; font-family: var(--sbm-font); cursor: pointer; text-decoration: underline; padding: 0; }
.sbm-cta {
  display: block; margin-top: 10px; padding: 10px 16px;
  background: var(--sbm-accent); color: #fff; border: none; border-radius: 6px;
  font-size: 13px; font-weight: 600; font-family: var(--sbm-font);
  cursor: pointer; text-align: center; text-decoration: none; transition: background .2s;
}
.sbm-cta:hover { background: var(--sbm-accent-hover); }

/* Empty / error */
.sbm-empty { grid-column: 1/-1; text-align: center; padding: 60px 24px; color: var(--sbm-text-mt); }
.sbm-empty svg { width: 48px; height: 48px; fill: var(--sbm-border); margin-bottom: 16px; }

/* Modal */
.sbm-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; z-index: 10000; opacity: 0; visibility: hidden; transition: opacity .2s, visibility .2s; }
.sbm-overlay.active { opacity: 1; visibility: visible; }
.sbm-modal { background: #fff; border-radius: 12px; padding: 28px; max-width: 560px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0,0,0,.2); transform: translateY(20px); transition: transform .2s; }
.sbm-overlay.active .sbm-modal { transform: translateY(0); }
.sbm-modal h3 { font-size: 18px; margin-bottom: 12px; }
.sbm-modal p { font-size: 13px; color: var(--sbm-text-lt); line-height: 1.7; }
.sbm-modal-x { float: right; background: none; border: none; font-size: 24px; cursor: pointer; color: var(--sbm-text-mt); line-height: 1; padding: 0; }

/* Skeleton */
.sbm-skel { background: linear-gradient(90deg,#f0f2f5 25%,#e8eaee 50%,#f0f2f5 75%); background-size: 200% 100%; animation: sbm-shimmer 1.5s infinite; border-radius: var(--sbm-radius); height: 280px; }
@keyframes sbm-shimmer { 0%{background-position:-200% 0} 100%{background-position:200% 0} }

/* Responsive */
@media (max-width:1100px) { .sbm-grid { grid-template-columns: repeat(3,1fr); } }
@media (max-width:768px) {
  .sbm-grid { grid-template-columns: repeat(2,1fr); gap: 14px; }
  .sbm-hero h1 { font-size: 22px; }
  .sbm-badge-num { font-size: 22px; }
  .sbm-card-body { padding: 12px 14px 14px; }
  .sbm-model { font-size: 15px; }
}
@media (max-width:480px) { .sbm-grid { grid-template-columns: 1fr; } }
</style>

<div class="sbm" id="shop-by-model">
  <div class="sbm-hero">
    <h1 class="sbm-title">Loading inventory...</h1>
    <p class="sbm-subtitle"></p>
  </div>
  <div class="sbm-bar">
    <div>
      <div class="sbm-results"></div>
      <div class="sbm-fresh"></div>
    </div>
    <div class="sbm-sort" hidden>
      <label for="sbm-sort-sel">Sort</label>
      <select id="sbm-sort-sel">
        <option value="count-desc">Most In Stock</option>
        <option value="price-asc">Price: Low to High</option>
        <option value="price-desc">Price: High to Low</option>
        <option value="alpha">Alphabetical</option>
      </select>
    </div>
  </div>
  <div class="sbm-grid"></div>
  <div class="sbm-overlay" role="dialog" aria-modal="true" aria-label="Pricing disclosure">
    <div class="sbm-modal">
      <button class="sbm-modal-x" aria-label="Close">&times;</button>
      <h3>Pricing Disclosure</h3>
      <p>
        Although every reasonable effort has been made to ensure the accuracy of the information
        contained on this site, absolute accuracy cannot be guaranteed. All vehicles are subject
        to prior sale. Price does not include applicable tax, title, license charges, and dealer fee.
        Prices shown may vary by trim, color, and available options. Please verify all information
        with the dealership.
      </p>
    </div>
  </div>
</div>

<script>
/* ═══════════════════ ROOFTOP CONFIG — edit this block only ═══════════════════ */
var SBM_CONFIG = {
  feedUrl:      "https://vcp.savvydealer.com/jgfwh-ford.json", // PRIMARY origin (VPS + Caddy)
  fallbackUrl:  "https://storage.googleapis.com/savvy-vcp-feeds/jgfwh-ford.json", // SECONDARY origin (GCS) - used only if the primary is unreachable
  // CSP NOTE: if the dealer site sends a Content-Security-Policy, connect-src must allow BOTH
  //   https://vcp.savvydealer.com AND https://storage.googleapis.com - otherwise the browser
  //   silently blocks the failover XHR (img-src already needs storage.googleapis.com for images).
  makeLabel:    "Ford",                                        // brand shown on cards (falls back to feed.makeLabel)
  dealerName:   "Jarrett-Gordon Ford",
  location:     "Winter Haven",
  state:        "FL",
  vcpBase:      "/vcp/new/ford/",   // model link = vcpBase + feed slug  → /vcp/new/ford/f-150
  linkTarget:   "_self",            // use "_top" only if this widget is ever iframed
  primaryColor: "#003478",
  accentColor:  "#d32f2f",
  showSort:     true,
  showFreshness:true
};
/* ═══════════════════════════════ END CONFIG ════════════════════════════════ */

(function(){
  "use strict";
  var C = SBM_CONFIG, D = null, MODELS = [], sort = "count-desc", bound = false;
  var CACHE_KEY = "sbm:" + C.feedUrl;
  var $  = function(s,c){ return (c||document).querySelector(s); };
  var $$ = function(s,c){ return [].slice.call((c||document).querySelectorAll(s)); };

  /* warm up DNS+TLS to the feed + image origins before we need them (cuts first-load latency) */
  preconnect(C.feedUrl, true);
  preconnect("https://storage.googleapis.com", false);
  function preconnect(url, cors){
    try {
      var o = new URL(url, location.href).origin, h = document.head || document.documentElement;
      var l = document.createElement("link"); l.rel = "preconnect"; l.href = o; if (cors) l.crossOrigin = "anonymous"; h.appendChild(l);
      var d = document.createElement("link"); d.rel = "dns-prefetch"; d.href = o; h.appendChild(d);
    } catch (e) {}
  }

  function init(){
    var root = $(".sbm");
    if (C.primaryColor) root.style.setProperty("--sbm-primary", C.primaryColor);
    if (C.accentColor)  root.style.setProperty("--sbm-accent",  C.accentColor);
    bind();
    var cached = readCache();
    if (cached) { D = cached; refresh(); }   // instant paint from the last good feed
    else showSkeletons();
    load(0);                                  // always revalidate in the background
  }

  /* fetch one feed URL; onErr() fires on network error, timeout, non-2xx, OR a 200 whose body
     is malformed/wrong-shape JSON (so a corrupt or error-serving primary also fails over to GCS) */
  function fetchFeed(url, onOk, onErr){
    var xhr = new XMLHttpRequest();
    xhr.open("GET", url, true);
    xhr.timeout = 8000;            // never hang on skeletons forever on a flaky mobile connection
    xhr.onload = function(){
      if (xhr.status >= 200 && xhr.status < 300) {
        var fresh;
        try { fresh = JSON.parse(xhr.responseText); }
        catch(e){ return onErr(); }
        if (!fresh || !Array.isArray(fresh.models)) return onErr();  // 200 but wrong shape (stale/error body) -> fail over, don't cache junk
        onOk(fresh);
      } else { onErr(); }
    };
    xhr.onerror   = function(){ onErr(); };
    xhr.ontimeout = function(){ onErr(); };
    xhr.send();
  }

  function accept(fresh){ D = fresh; writeCache(fresh); refresh(); }   // shared cache key (primary feedUrl)

  /* primary origin first */
  function load(attempt){
    fetchFeed(C.feedUrl, accept, function(){ retryOrFallback(attempt); });
  }

  /* one silent retry on the primary (covers a transient cellular drop), then a single
     attempt against the GCS secondary origin (survives a full VPS/Caddy outage), then
     give up. giveUp() only surfaces an error if nothing is cached. */
  function retryOrFallback(attempt){
    if (attempt < 1) { setTimeout(function(){ load(attempt + 1); }, 1200); return; }
    if (C.fallbackUrl && C.fallbackUrl !== C.feedUrl) {
      fetchFeed(C.fallbackUrl, accept, function(){
        giveUp("Could not load inventory right now. Please refresh to try again.");
      });
    } else {
      giveUp("Could not load inventory right now. Please refresh to try again.");
    }
  }
  function giveUp(msg){ if (!D) showError(msg); }   // keep cached content if we have it

  function refresh(){
    MODELS = normalize(D.models || []);
    applyHeader();
    render();
    injectJsonLd();
  }

  /* stale-while-revalidate: instant render from the last feed on repeat visits, then refresh */
  function readCache(){
    try {
      var raw = localStorage.getItem(CACHE_KEY); if (!raw) return null;
      var o = JSON.parse(raw);
      if (!o || !o.t || (Date.now() - o.t) > 864e5) return null; // ignore caches older than 24h
      return o.d && o.d.models ? o.d : null;
    } catch (e) { return null; }
  }
  function writeCache(d){
    try { localStorage.setItem(CACHE_KEY, JSON.stringify({ t: Date.now(), d: d })); } catch (e) {}
  }

  /* feed model shape → card shape */
  function normalize(rows){
    return rows.map(function(m){
      return {
        name:  m.name,
        slug:  m.slug,
        count: (m.inStock != null ? m.inStock : m.count) || 0,
        price: (m.startingAt != null ? m.startingAt : m.startingPrice) || 0,
        image: m.imgUrl || m.image || "",
        badge: m.badge || null
      };
    }).filter(function(m){ return m.count > 0; });
  }

  /* SEO / AI-search: drop the feed's prebuilt schema.org JSON-LD into the page head (update in place) */
  function injectJsonLd(){
    try {
      if (!D || !D.jsonLd) return;
      var s = document.getElementById("sbm-jsonld");
      if (!s) { s = document.createElement("script"); s.type = "application/ld+json"; s.id = "sbm-jsonld"; (document.head || document.body).appendChild(s); }
      s.text = JSON.stringify(D.jsonLd);
    } catch (e) {}
  }

  function applyHeader(){
    var make = C.makeLabel || D.makeLabel || "";
    $(".sbm-title").textContent = "New " + make + " Vehicles for Sale in " + C.location + ", " + C.state;
    $(".sbm-subtitle").textContent = "Browse every " + make + " model we stock. Click any model to see live inventory.";
    if (C.showFreshness && D.updated) {
      $(".sbm-fresh").innerHTML = '<span></span>Inventory updated ' + esc(freshness(D.updated));
    }
    if (C.showSort) $(".sbm-sort").hidden = false;
  }

  function bind(){
    if (bound) return; bound = true;
    var ov = $(".sbm-overlay");
    if (ov) ov.addEventListener("click", function(e){ if (e.target === ov) closeModal(); });
    var x = $(".sbm-modal-x"); if (x) x.addEventListener("click", closeModal);
    document.addEventListener("keydown", function(e){ if (e.key === "Escape") closeModal(); });
    var sel = $("#sbm-sort-sel");
    if (sel) sel.addEventListener("change", function(){ sort = this.value; render(); });
  }

  var SORTERS = {
    "count-desc": function(a,b){ return b.count - a.count; },
    "price-asc":  function(a,b){ return a.price - b.price; },
    "price-desc": function(a,b){ return b.price - a.price; },
    "alpha":      function(a,b){ return a.name.localeCompare(b.name); }
  };

  function render(){
    var g = $(".sbm-grid"); if (!g) return;
    var list = MODELS.slice().sort(SORTERS[sort] || SORTERS["count-desc"]);
    var total = list.reduce(function(s,m){ return s + m.count; }, 0);
    $(".sbm-results").innerHTML = "Showing <strong>" + total + " vehicles</strong> across <strong>" + list.length + "</strong> models";

    if (!list.length) {
      g.innerHTML = '<div class="sbm-empty"><svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0016 9.5 6.5 6.5 0 109.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg><p>No models are in stock right now. Please check back soon.</p></div>';
      return;
    }
    g.innerHTML = list.map(function(m, i){ return card(m, i); }).join("");
    $$(".sbm-disc button", g).forEach(function(b){
      b.addEventListener("click", function(e){ e.preventDefault(); e.stopPropagation(); openModal(); });
    });
  }

  var CHIP = { hot: "#1 Seller", ev: "Electric", low: "Low Stock" };

  function card(m, i){
    var make  = C.makeLabel || D.makeLabel || "";
    var price = m.price ? "$" + Number(m.price).toLocaleString("en-US") : "Call for price";
    var href  = m.slug ? (C.vcpBase + m.slug) : "#";
    var chip  = m.badge && CHIP[m.badge] ? '<span class="sbm-chip ' + esc(m.badge) + '">' + esc(CHIP[m.badge]) + '</span>' : "";
    var img   = m.image
      ? '<img src="' + esc(m.image) + '" alt="' + esc(make) + ' ' + esc(m.name) + '" decoding="async" ' + (i < 4 ? 'loading="eager" fetchpriority="high"' : 'loading="lazy"') + '>'
      : '<svg class="sbm-placeholder" viewBox="0 0 24 24" fill="#c0c4cc"><path d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z"/></svg>';

    return '<a class="sbm-card" href="' + esc(href) + '" target="' + esc(C.linkTarget || "_self") + '" aria-label="View ' + esc(make) + ' ' + esc(m.name) + ' inventory">'
      + '<div class="sbm-card-img">' + img + chip
      + '<div class="sbm-badge"><span class="sbm-badge-num">' + m.count + '</span><span class="sbm-badge-lbl">Available</span></div>'
      + '</div>'
      + '<div class="sbm-card-body">'
      + '<div class="sbm-model"><span class="sbm-make">' + esc(make) + '</span> ' + esc(m.name) + '</div>'
      + '<div class="sbm-price">Starting at <strong>' + esc(price) + '</strong></div>'
      + '<div class="sbm-disc"><button type="button">Disclosure</button></div>'
      + '<span class="sbm-cta">View Inventory</span>'
      + '</div></a>';
  }

  function showSkeletons(){
    var g = $(".sbm-grid"); if (!g) return;
    var h = ""; for (var i=0;i<8;i++) h += '<div class="sbm-skel"></div>';
    g.innerHTML = h;
  }
  function showError(msg){
    var g = $(".sbm-grid"); if (g) g.innerHTML = '<div class="sbm-empty"><p>' + esc(msg) + '</p></div>';
    $(".sbm-results").textContent = "";
  }
  function openModal(){ var o = $(".sbm-overlay"); if (o) o.classList.add("active"); }
  function closeModal(){ var o = $(".sbm-overlay"); if (o) o.classList.remove("active"); }

  function freshness(iso){
    var then = new Date(iso).getTime();
    if (isNaN(then)) return "recently";
    var hrs = Math.floor((Date.now() - then) / 3.6e6);
    if (hrs < 1)  return "in the last hour";
    if (hrs < 24) return hrs + (hrs === 1 ? " hour ago" : " hours ago");
    var days = Math.floor(hrs / 24);
    return days + (days === 1 ? " day ago" : " days ago");
  }
  function esc(s){ var d = document.createElement("div"); d.textContent = (s == null ? "" : s); return d.innerHTML; }

  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
  else init();
})();
</script>
:::