← back to 1940swallpaper

public/corner-nav.js

278 lines

/* corner-nav.js — universal corner nav for the DW fleet.
 *
 * What it does: injects two fixed widgets —
 *   TOP-RIGHT pill (3 icons):
 *     1. Bag       → links to designerwallcoverings.com search for this site's keyword
 *     2. Magnifier → toggles a search overlay; filters [data-grid] if present, else /search
 *     3. Menu      → toggles a drawer with the site's existing nav + sister-site links
 *   TOP-LEFT hamburger (its own control, per Steve 2026-07-03):
 *     • Account   → opens the trade-account / sign-in panel (slides from the LEFT to
 *                   match its left-corner trigger). Formerly the "Person" icon that
 *                   lived in the top-right pill; relocated to its own upper-left hamburger.
 *
 * Self-styled. Idempotent. Drop-in:  <script src="/corner-nav.js" defer></script>
 *
 * Per-site overrides: set `window.CornerNavConfig = { ... }` BEFORE the script loads.
 *   { bagUrl, accountUrl, searchSelector, menuLinks: [{name,url}], skipMenu: bool }
 *
 * Reference: Gucci / YSL top-right luxury nav pattern. Per Steve 2026-05-13.
 */
(function () {
  'use strict';
  if (window.__cornerNavLoaded) return;
  window.__cornerNavLoaded = true;

  var CFG = window.CornerNavConfig || {};
  var DOMAIN = location.hostname.replace(/^www\./, '');
  var STEM   = DOMAIN.replace(/\..+$/, '');
  function titleCase(s) {
    // 'corkwallcovering' → 'Cork Wallcovering'
    // 'hospitalitywallpaper' → 'Hospitality Wallpaper'
    return s
      .replace(/(wallcovering|wallpaper|fabric|wallcoverings|walls|paper|cover|covering)/gi, ' $1')
      .replace(/\s+/g, ' ')
      .trim()
      .replace(/\b\w/g, function (c) { return c.toUpperCase(); });
  }
  var rawBrand = (document.querySelector('meta[name="brand"]') || {}).content
              || ((document.querySelector('.ns-name') || {}).textContent || '').trim()
              || STEM;
  // Title-case if the source is all-lowercase (the .ns-name spans on DW sites
  // are intentionally lowercase for typography; the menu panel header wants
  // proper case so it doesn't read as a slug).
  var BRAND = (rawBrand === rawBrand.toLowerCase()) ? titleCase(rawBrand) : rawBrand;

  var BAG_URL     = CFG.bagUrl     || 'https://designerwallcoverings.com/search?q=' + encodeURIComponent(BRAND);
  var ACCOUNT_URL = CFG.accountUrl || 'https://designerwallcoverings.com/account';
  var INFO_EMAIL  = CFG.infoEmail  || ('info@' + DOMAIN);

  // ---------- styles (scoped via .cnav prefix) ----------
  var css = '\
.cnav-wrap{position:fixed;top:14px;right:18px;z-index:9000;display:flex;align-items:center;gap:18px;padding:10px 16px;background:rgba(10,10,10,0.78);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.08);border-radius:999px;font-family:-apple-system,"SF Pro Text",system-ui,sans-serif;color:#f5f1e8;font-size:13px;letter-spacing:0.04em;text-transform:uppercase}\
.cnav-loginwrap{position:fixed;top:14px;left:18px;z-index:9000;display:flex;align-items:center;gap:10px;padding:10px 16px;background:rgba(10,10,10,0.78);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.08);border-radius:999px;font-family:-apple-system,"SF Pro Text",system-ui,sans-serif;color:#f5f1e8;font-size:13px;letter-spacing:0.04em;text-transform:uppercase}\
.cnav-btn{background:none;border:0;cursor:pointer;color:inherit;padding:4px;display:flex;align-items:center;gap:8px;transition:opacity .2s}\
.cnav-btn:hover{opacity:0.7}\
.cnav-btn svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}\
.cnav-btn .cnav-lbl{font-size:13px;font-weight:400;letter-spacing:0.06em}\
.cnav-overlay{position:fixed;inset:0;z-index:8999;background:rgba(0,0,0,0.5);opacity:0;pointer-events:none;transition:opacity .2s}\
.cnav-overlay.show{opacity:1;pointer-events:auto}\
.cnav-panel{position:fixed;top:0;right:0;height:100vh;width:min(420px,92vw);background:#0a0a0a;color:#f5f1e8;z-index:9001;transform:translateX(100%);transition:transform .3s ease;padding:88px 32px 24px;overflow-y:auto;border-left:1px solid rgba(255,255,255,0.08);box-sizing:border-box}\
.cnav-panel.show{transform:translateX(0)}\
.cnav-panel-left{left:0;right:auto;transform:translateX(-100%);border-left:0;border-right:1px solid rgba(255,255,255,0.08)}\
.cnav-panel h3{font-family:"Playfair Display",Georgia,serif;font-weight:400;font-size:20px;margin:0 0 16px;letter-spacing:0.04em}\
.cnav-panel .cnav-close{position:absolute;top:18px;right:18px;background:none;border:0;color:#f5f1e8;font-size:28px;cursor:pointer;line-height:1;padding:6px}\
.cnav-panel a, .cnav-panel button.cnav-link{display:block;padding:12px 0;color:#f5f1e8;text-decoration:none;border-bottom:1px solid rgba(255,255,255,0.08);font-size:15px;letter-spacing:0.02em;background:none;border-left:0;border-right:0;border-top:0;width:100%;text-align:left;cursor:pointer;font-family:inherit}\
.cnav-panel a:hover, .cnav-panel button.cnav-link:hover{opacity:0.7}\
.cnav-search{padding:14px 16px;width:100%;font-size:18px;background:transparent;border:0;border-bottom:1px solid rgba(255,255,255,0.18);color:#f5f1e8;outline:none;box-sizing:border-box;margin-bottom:18px;font-family:inherit}\
.cnav-search::placeholder{color:rgba(245,241,232,0.4)}\
.cnav-sub{font-size:12px;color:rgba(245,241,232,0.55);margin:18px 0 8px;letter-spacing:0.08em;text-transform:uppercase}\
.cnav-panel a.cnav-res{display:flex;align-items:center;gap:12px;padding:10px 4px;border-bottom:1px solid rgba(255,255,255,0.08);text-decoration:none;color:#f5f1e8}\
.cnav-panel a.cnav-res:hover{opacity:1;background:rgba(255,255,255,0.04)}\
.cnav-res img,.cnav-res .cnav-res-ph{width:48px;height:48px;object-fit:cover;border-radius:4px;background:rgba(255,255,255,0.06);flex:0 0 auto}\
.cnav-res-t{font-size:14px;letter-spacing:0.02em;line-height:1.3;text-transform:none}\
.cnav-searching,.cnav-nores{font-size:13px;color:rgba(245,241,232,0.55);padding:12px 4px;letter-spacing:0.02em}\
@media (max-width:520px){.cnav-wrap{top:10px;right:10px;padding:8px 12px;gap:12px}.cnav-loginwrap{top:10px;left:10px;padding:8px 12px;gap:8px}.cnav-btn .cnav-lbl{display:none}.cnav-btn svg{width:20px;height:20px}}\
';
  var styleEl = document.createElement('style');
  styleEl.setAttribute('data-cnav', '1');
  styleEl.appendChild(document.createTextNode(css));
  document.head.appendChild(styleEl);

  // ---------- icons ----------
  var ICONS = {
    bag:    '<svg viewBox="0 0 24 24"><path d="M6 7h12l-1 13H7L6 7Z"/><path d="M9 7V5a3 3 0 0 1 6 0v2"/></svg>',
    person: '<svg viewBox="0 0 24 24"><circle cx="12" cy="8" r="3.2"/><path d="M5 20c0-3.5 3-6 7-6s7 2.5 7 6"/></svg>',
    search: '<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="6"/><path d="m20 20-4.3-4.3"/></svg>',
    menu:   '<svg viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></svg>',
  };

  // ---------- widget ----------
  var wrap = document.createElement('div');
  wrap.className = 'cnav-wrap';
  wrap.setAttribute('role', 'navigation');
  wrap.setAttribute('aria-label', 'Primary');
  wrap.innerHTML = '\
    <a class="cnav-btn" href="' + BAG_URL + '" aria-label="Shop"     title="Shop">'    + ICONS.bag    + '</a>\
    <button class="cnav-btn" id="cnav-search"  aria-label="Search"   title="Search">'  + ICONS.search + '</button>\
    <button class="cnav-btn" id="cnav-menu"    aria-label="Menu"     title="Menu">'    + ICONS.menu   + '<span class="cnav-lbl">Menu</span></button>\
  ';

  // Top-left login hamburger — its own control (Steve 2026-07-03). Opens the
  // trade-account / sign-in panel; the panel slides from the LEFT to match.
  var loginwrap = document.createElement('div');
  loginwrap.className = 'cnav-loginwrap';
  loginwrap.setAttribute('role', 'navigation');
  loginwrap.setAttribute('aria-label', 'Account');
  loginwrap.innerHTML = '\
    <button class="cnav-btn" id="cnav-account" aria-label="Account" title="Sign in / Account">' + ICONS.menu + '<span class="cnav-lbl">Account</span></button>\
  ';

  var overlay = document.createElement('div');
  overlay.className = 'cnav-overlay';

  // ---------- panels ----------
  function panel(html) {
    var p = document.createElement('aside');
    p.className = 'cnav-panel';
    p.innerHTML = '<button class="cnav-close" aria-label="Close">×</button>' + html;
    return p;
  }
  function openPanel(p) { overlay.classList.add('show'); p.classList.add('show'); }
  function closeAll() {
    overlay.classList.remove('show');
    document.querySelectorAll('.cnav-panel.show').forEach(function (el) { el.classList.remove('show'); });
  }

  // Account panel
  var acct = panel('\
    <h3>Trade account</h3>\
    <p class="cnav-sub" style="margin-top:0">Verified-buyer access to memo samples, trade pricing, and project quoting.</p>\
    <a href="' + ACCOUNT_URL + '">Sign in / register →</a>\
    <a href="mailto:' + INFO_EMAIL + '?subject=' + encodeURIComponent('Trade account inquiry') + '">Email ' + INFO_EMAIL + '</a>\
    <a href="tel:+18883734564">Call (888) 373-4564 · M–F 9–5 PT</a>\
  ');
  acct.classList.add('cnav-panel-left'); // slides from the left, under the login hamburger

  // Search panel — filters [data-grid] if present, otherwise dispatches to /search
  var hasGrid = document.querySelector('[data-grid]') || document.querySelector('.product-grid') || document.querySelector('#shop');
  var srch = panel('\
    <h3>Search</h3>\
    <input type="search" class="cnav-search" id="cnav-search-input" placeholder="Search ' + BRAND + '..." autocomplete="off">\
    <div class="cnav-sub">Type to search · enter opens the first result · ESC to close</div>\
    <div id="cnav-search-results" style="margin-top:18px"></div>\
  ');

  // Menu panel — auto-extract existing site nav
  var existingLinks = [];
  document.querySelectorAll('.ns-nav a, header nav a, nav.primary a').forEach(function (a) {
    var name = (a.textContent || '').trim();
    var url  = a.getAttribute('href') || '#';
    if (name && url && !existingLinks.some(function (e) { return e.name === name; })) {
      existingLinks.push({ name: name, url: url });
    }
  });
  if (!existingLinks.length) {
    existingLinks = [
      { name: 'Home',  url: '/' },
      { name: 'About', url: '/about' },
      { name: 'Shop',  url: BAG_URL },
    ];
  }
  var menuLinks = (CFG.menuLinks && CFG.menuLinks.length) ? CFG.menuLinks : existingLinks;
  var sisterLinks = [
    { name: 'Designer Wallcoverings', url: 'https://designerwallcoverings.com' },
    { name: 'Novasuede',              url: 'https://novasuede.com' },
    { name: 'Phillipe Romano',        url: 'https://philipperomano.com' },
    { name: 'Architectural Wallcoverings', url: 'https://architecturalwallcoverings.com' },
  ];
  var mnu = panel('\
    <h3>' + BRAND + '</h3>\
    ' + menuLinks.map(function (l) { return '<a href="' + l.url + '">' + l.name + '</a>'; }).join('') + '\
    <div class="cnav-sub" style="margin-top:24px">Sister sites</div>\
    ' + sisterLinks.filter(function (l) { return l.url.indexOf(DOMAIN) === -1; })
                   .map(function (l) { return '<a href="' + l.url + '">' + l.name + '</a>'; }).join('') + '\
  ');

  document.body.appendChild(wrap);
  document.body.appendChild(loginwrap);
  document.body.appendChild(overlay);
  document.body.appendChild(acct);
  document.body.appendChild(srch);
  document.body.appendChild(mnu);

  // ---------- wiring ----------
  document.getElementById('cnav-account').addEventListener('click', function (e) { e.preventDefault(); openPanel(acct); });
  document.getElementById('cnav-search').addEventListener('click',  function (e) { e.preventDefault(); openPanel(srch); setTimeout(function () { document.getElementById('cnav-search-input').focus(); }, 320); });
  document.getElementById('cnav-menu').addEventListener('click',    function (e) { e.preventDefault(); openPanel(mnu); });
  overlay.addEventListener('click', closeAll);
  document.querySelectorAll('.cnav-close').forEach(function (b) { b.addEventListener('click', closeAll); });
  document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeAll(); });

  // Search — LIVE preview as you type. Queries the site product API (which
  // searches title + tags + description) on a short debounce and renders
  // clickable thumbnail results. Falls back to filtering the on-page grid
  // only when no API is reachable, so pages without /api/products still work.
  var input = document.getElementById('cnav-search-input');
  var results = document.getElementById('cnav-search-results');
  var SEARCH_API = CFG.searchApi || '/api/products';
  var _st, _seq = 0;

  function cnEsc(s) {
    return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
    });
  }
  function cnSafeUrl(u) {
    u = String(u == null ? '' : u).trim();
    if (!u) return '';
    var m = u.match(/^([a-z][a-z0-9+.\-]*):/i);
    if (m && !/^https?$/i.test(m[1])) return '#'; // block javascript:, data:, vbscript:, ...
    return u;
  }
  function resHref(p) {
    return cnSafeUrl(p.product_url || p.url || (p.handle ? '/products/' + p.handle : '#'));
  }
  function resImg(p)   { return cnSafeUrl(p.image_url || p.image || p.featured_image || p.thumbnail || ''); }
  function resTitle(p) { return p.title || p.name || p.sku || 'Untitled'; }

  function renderResults(items, q) {
    if (!items || !items.length) {
      results.innerHTML = '<div class="cnav-nores">No matches for &ldquo;' + cnEsc(q) + '&rdquo;</div>';
      return;
    }
    results.innerHTML = items.slice(0, 8).map(function (p) {
      var img = resImg(p);
      return '<a class="cnav-res" href="' + cnEsc(resHref(p)) + '">' +
        (img ? '<img loading="lazy" src="' + cnEsc(img) + '" alt="">' : '<span class="cnav-res-ph"></span>') +
        '<span class="cnav-res-t">' + cnEsc(resTitle(p)) + '</span></a>';
    }).join('');
  }

  // Fallback: filter whatever cards are already rendered on the page.
  function gridFilter(q) {
    if (!hasGrid) { results.innerHTML = ''; return; }
    var cards = hasGrid.querySelectorAll('[data-card], .product-card, .pcard, .grid-item, .card');
    var shown = 0;
    cards.forEach(function (c) {
      var t = c.textContent.toLowerCase();
      var hit = !q || t.indexOf(q) !== -1;
      c.style.display = hit ? '' : 'none';
      if (hit) shown++;
    });
    results.textContent = q ? (shown + ' result' + (shown === 1 ? '' : 's') + ' on this page') : '';
  }

  if (input) {
    input.addEventListener('input', function () {
      var q = input.value.trim();
      clearTimeout(_st);
      if (!q) { results.innerHTML = ''; gridFilter(''); return; }
      results.innerHTML = '<div class="cnav-searching">Searching…</div>';
      var myseq = ++_seq;
      _st = setTimeout(function () {
        fetch(SEARCH_API + '?q=' + encodeURIComponent(q) + '&limit=8')
          .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
          .then(function (d) {
            if (myseq !== _seq) return; // a newer keystroke already fired
            var items = d.items || d.products || (Array.isArray(d) ? d : []);
            renderResults(items, q);
          })
          .catch(function () {
            if (myseq !== _seq) return;
            gridFilter(q.toLowerCase()); // no API → live-filter the page instead
          });
      }, 180);
    });
    input.addEventListener('keydown', function (e) {
      if (e.key === 'Enter') {
        var q = input.value.trim();
        if (!q) return;
        var first = results.querySelector('a.cnav-res');
        if (first) { location.href = first.getAttribute('href'); return; }
        if (hasGrid) { closeAll(); hasGrid.scrollIntoView({ behavior: 'smooth' }); }
        else { location.href = '/search?q=' + encodeURIComponent(q); }
      }
    });
  }
})();