← back to Dw Pairs Well

deploy/shopify/design-coordinate.liquid

369 lines

{%- comment -%}
    Design Coordinate v7 — Steve's spec:
      • Catalog grid: 6 stripe/plaid/check coordinates that use the source palette ("active patterns")
      • Density slider 3-12 cols (default 6), localStorage-persisted
      • Auto Generated: 6 SVG-poster placeholders seeded from palette (no "AI" wording, customer-facing)
      • Vendor picker: SW / DE / BM / F&B (default on) + Behr / PPG / RAL (opt-in)
      • Each card shows palette dots + selected vendors' paint chips
      • Compact: smaller CTA pill + headings (v7 "debig"); place AFTER the product image in the theme.
    Service: https://pairs.designerwallcoverings.com  (commit history at ~/Projects/dw-pairs-well)
    CSS namespace kept as .pairs-well__* — only the button TEXT is "Design Coordinate".
  {%- endcomment -%}
  <section
    id="pairs-well-with"
    class="dc-section"
    data-pairs-endpoint="https://pairs.designerwallcoverings.com/api/pairs"
    data-ai-endpoint="https://pairs.designerwallcoverings.com/api/ai-coordinates"
    data-product-handle="{{ product.handle }}"
    data-product-sku="{{ product.metafields.custom.manufacturer_sku | default: product.selected_or_first_available_variant.sku | escape }}"
    style="margin: 1.5em 0 2em; clear: both;"
    hidden
  >
    <button type="button" class="pairs-well__cta" aria-expanded="false">
      <span class="pairs-well__cta-text">Design Coordinate</span>
      <span class="pairs-well__cta-icon" aria-hidden="true">+</span>
    </button>

    <div class="pairs-well__source-palette" id="pairs-well-source-palette" hidden></div>

    <div class="pairs-well__vendor-picker" id="pairs-well-vendor-picker" hidden>
      <h4>Paint brands</h4>
      <div class="chips">
        <label><input type="checkbox" value="sw"   checked> Sherwin-Williams</label>
        <label><input type="checkbox" value="de"   checked> Dunn-Edwards</label>
        <label><input type="checkbox" value="bm"   checked> Benjamin Moore</label>
        <label><input type="checkbox" value="fb"   checked> Farrow &amp; Ball</label>
        <label><input type="checkbox" value="behr"        > Behr</label>
        <label><input type="checkbox" value="ppg"         > PPG</label>
        <label><input type="checkbox" value="ral"         > RAL</label>
      </div>
    </div>

    <div class="pairs-well__catalog" id="pairs-well-catalog" hidden>
      <div class="pairs-well__panel-head">
        <h3>Stripes &amp; Plaids from our catalog <span class="count" id="pairs-well-catalog-count"></span></h3>
        <div class="pairs-well__controls">
          <label for="pairs-well-cols-catalog">Columns</label>
          <input id="pairs-well-cols-catalog" type="range" min="3" max="12" value="6" step="1">
          <span class="pairs-well__cols-val" id="pairs-well-cols-catalog-val">6</span>
        </div>
      </div>
      <div id="pairs-well-grid" class="pairs-well__grid"></div>
    </div>

    <div class="pairs-well__ai" id="pairs-well-ai" hidden>
      <div class="pairs-well__panel-head">
        <h3>Auto Generated <span class="ai-badge">Preview · 6 options</span></h3>
        <div class="pairs-well__controls">
          <label for="pairs-well-cols-ai">Columns</label>
          <input id="pairs-well-cols-ai" type="range" min="3" max="12" value="6" step="1">
          <span class="pairs-well__cols-val" id="pairs-well-cols-ai-val">6</span>
        </div>
      </div>
      <div id="pairs-well-ai-grid" class="pairs-well__grid pairs-well__grid--ai"></div>
    </div>
  </section>

  <style>
    #pairs-well-with { font-family: inherit; --cols-catalog: 6; --cols-ai: 6; }
    .pairs-well__cta {
      display: inline-flex; align-items: center; justify-content: center; gap: 8px;
      width: auto; max-width: 100%; padding: 10px 18px; margin: 0;
      background: #111; color: #fff;
      border: 0; border-radius: 999px;
      font-size: 12px; font-weight: 500; letter-spacing: 0.06em;
      text-transform: uppercase; cursor: pointer;
      transition: background 160ms ease, transform 120ms ease;
    }
    .pairs-well__cta:hover  { background: #2a2a2a; }
    .pairs-well__cta:active { transform: scale(0.995); }
    .pairs-well__cta[aria-expanded="true"] .pairs-well__cta-icon { transform: rotate(45deg); }
    .pairs-well__cta-icon { display: inline-block; font-size: 22px; line-height: 1; transition: transform 200ms ease; }

    .pairs-well__source-palette { margin: 18px 0 0; }
    .pairs-well__source-palette h4 { margin: 0 0 8px; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #777; }
    .pairs-well__source-palette table { border-collapse: collapse; width: 100%; font-size: 12px; }
    .pairs-well__source-palette th, .pairs-well__source-palette td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #f0ebe2; }
    .pairs-well__source-palette th { font-weight: 500; color: #777; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
    .pairs-well__source-palette td.swatch { width: 28px; }

    .pw-dot    { display: inline-block; width: 18px; height: 18px; border-radius: 50%; border: 1px solid rgba(0,0,0,0.12); vertical-align: middle; }
    .pw-square { display: inline-block; width: 13px; height: 13px; border-radius: 3px; border: 1px solid rgba(0,0,0,0.12); vertical-align: middle; margin-right: 4px; }
    .pw-code   { color: #888; font-variant-numeric: tabular-nums; font-size: 11px; }

    .pairs-well__vendor-picker { margin: 18px 0 0; }
    .pairs-well__vendor-picker h4 { margin: 0 0 8px; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: #777; }
    .pairs-well__vendor-picker .chips { display: flex; flex-wrap: wrap; gap: 6px; }
    .pairs-well__vendor-picker label {
      font-size: 12px; padding: 5px 10px; border: 1px solid #e7e7e7; border-radius: 999px;
      cursor: pointer; background: #fff; user-select: none;
    }
    .pairs-well__vendor-picker label:has(input:checked) { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
    .pairs-well__vendor-picker input { display: none; }

    .pairs-well__panel-head {
      display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
      gap: 12px; margin: 16px 0 10px;
    }
    .pairs-well__panel-head h3 { margin: 0; font: 500 15px/1.2 ui-serif, Georgia, serif; }
    .pairs-well__panel-head .count { color: #888; font-size: 12px; margin-left: 6px; font-family: -apple-system, sans-serif; font-weight: 400; }
    .pairs-well__panel-head .ai-badge {
      display: inline-block; padding: 3px 8px; background: #2a2a2a; color: #fff;
      border-radius: 999px; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
      margin-left: 8px; font-family: -apple-system, sans-serif; font-weight: 600;
    }
    .pairs-well__controls {
      display: flex; align-items: center; gap: 10px;
      font-size: 11px; color: #777; text-transform: uppercase; letter-spacing: 0.06em;
    }
    .pairs-well__controls input[type=range] { width: 160px; accent-color: #1a1a1a; }
    .pairs-well__cols-val { font-variant-numeric: tabular-nums; color: #1a1a1a; min-width: 1.5em; text-align: right; font-weight: 600; }

    .pairs-well__grid {
      display: grid; gap: 14px; margin-top: 6px;
      grid-template-columns: repeat(var(--cols-catalog), minmax(0, 1fr));
    }
    .pairs-well__grid--ai { grid-template-columns: repeat(var(--cols-ai), minmax(0, 1fr)); }
    @media (max-width: 1100px) { .pairs-well__grid { grid-template-columns: repeat(min(6, var(--cols-catalog)), minmax(0, 1fr)); } .pairs-well__grid--ai { grid-template-columns: repeat(min(6, var(--cols-ai)), minmax(0, 1fr)); } }
    @media (max-width: 900px)  { .pairs-well__grid { grid-template-columns: repeat(min(4, var(--cols-catalog)), minmax(0, 1fr)); } .pairs-well__grid--ai { grid-template-columns: repeat(min(4, var(--cols-ai)), minmax(0, 1fr)); } }
    @media (max-width: 600px)  { .pairs-well__grid { grid-template-columns: repeat(min(2, var(--cols-catalog)), minmax(0, 1fr)); } .pairs-well__grid--ai { grid-template-columns: repeat(min(2, var(--cols-ai)), minmax(0, 1fr)); } }

    .pairs-well__card {
      display: block; text-decoration: none; color: inherit;
      background: #fff; border: 1px solid #e7e7e7; border-radius: 6px;
      overflow: hidden; transition: box-shadow 160ms ease, transform 160ms ease;
    }
    .pairs-well__card:hover { box-shadow: 0 6px 20px rgba(0,0,0,0.08); transform: translateY(-2px); }
    .pairs-well__card--ai { border-style: dashed; border-color: #d9c7a8; }
    .pairs-well__img-wrap { aspect-ratio: 1 / 1; background: #f4f4f4; overflow: hidden; }
    .pairs-well__img { width: 100%; height: 100%; object-fit: cover; display: block; }
    .pairs-well__meta { padding: 10px 12px 12px; }
    .pairs-well__title { margin: 0 0 4px; font-size: 13px; font-weight: 600; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
    .pairs-well__vendor { margin: 0 0 6px; font-size: 10px; color: #777; letter-spacing: 0.04em; text-transform: uppercase; }
    .pairs-well__why { display: inline-block; padding: 2px 7px; background: #f4ede4; color: #5a4628; border-radius: 999px; font-size: 10px; line-height: 1.4; margin-bottom: 8px; }
    .pairs-well__card--ai .pairs-well__why { background: #efe6f5; color: #5b3478; }
    .pairs-well__palette { display: flex; gap: 3px; margin: 0 0 8px; flex-wrap: wrap; }
    .pairs-well__palette .pw-dot { width: 12px; height: 12px; }
    .pairs-well__paints { border-top: 1px dashed #ebe4d8; margin-top: 4px; padding-top: 6px; font-size: 10px; line-height: 1.5; color: #444; }
    .pairs-well__paints .label { color: #888; font-weight: 600; margin-right: 4px; letter-spacing: 0.04em; }
    .pairs-well__paints .row { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

    .pairs-well__skeleton { background: linear-gradient(90deg, #eee 0%, #f6f6f6 50%, #eee 100%); background-size: 200% 100%; animation: pwShim 1.2s linear infinite; aspect-ratio: 1 / 1; border-radius: 6px; }
    @keyframes pwShim { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
    .pairs-well__empty { padding: 18px; color: #777; font-size: 14px; grid-column: 1 / -1; }
  </style>

  <script>
    (function () {
      var root = document.getElementById('pairs-well-with');
      if (!root) return;

      var endpointPairs = root.getAttribute('data-pairs-endpoint');
      var endpointAi    = root.getAttribute('data-ai-endpoint');
      var handle        = root.getAttribute('data-product-handle');
      var sku           = root.getAttribute('data-product-sku');
      // For external sources (wallco.ai design page, etc.) that aren't in our
      // Shopify catalog: pass a comma-separated hex palette + title instead.
      var palette       = root.getAttribute('data-source-palette');
      var sourceTitle   = root.getAttribute('data-source-title') || '';
      var sourceImage   = root.getAttribute('data-source-image') || '';
      var hasIdentifier = (sku && sku.length) || (handle && handle.length) || (palette && palette.length);
      if (!hasIdentifier) return;
      root.hidden = false;

      var cta            = root.querySelector('.pairs-well__cta');
      var catalogPanel   = document.getElementById('pairs-well-catalog');
      var catalogGrid    = document.getElementById('pairs-well-grid');
      var catalogCols    = document.getElementById('pairs-well-cols-catalog');
      var catalogColsVal = document.getElementById('pairs-well-cols-catalog-val');
      var catalogCount   = document.getElementById('pairs-well-catalog-count');
      var aiPanel        = document.getElementById('pairs-well-ai');
      var aiGrid         = document.getElementById('pairs-well-ai-grid');
      var aiCols         = document.getElementById('pairs-well-cols-ai');
      var aiColsVal      = document.getElementById('pairs-well-cols-ai-val');
      var sourcePalette  = document.getElementById('pairs-well-source-palette');
      var vendorPicker   = document.getElementById('pairs-well-vendor-picker');
      var vendorChecks   = vendorPicker.querySelectorAll('input[type=checkbox]');
      var loaded = false;
      var cachedPairs = null, cachedAi = null, cachedSource = null;

      var VENDOR_LABEL = { sw:'Sherwin-Williams', de:'Dunn-Edwards', bm:'Benjamin Moore', fb:'Farrow & Ball', behr:'Behr', ppg:'PPG', ral:'RAL' };
      var VENDOR_SHORT = { sw:'SW', de:'DE', bm:'BM', fb:'F&B', behr:'Behr', ppg:'PPG', ral:'RAL' };
      var DEFAULT_VENDORS = ['sw','de','bm','fb'];

      function selectedVendors() {
        var out = [];
        vendorChecks.forEach(function (cb) { if (cb.checked) out.push(cb.value); });
        return out.length ? out : DEFAULT_VENDORS.slice();
      }
      try {
        var stored = JSON.parse(localStorage.getItem('pw.vendors') || 'null');
        if (Array.isArray(stored) && stored.length) {
          vendorChecks.forEach(function (cb) { cb.checked = stored.indexOf(cb.value) !== -1; });
        }
      } catch (_) {}
      function persistVendors() {
        try { localStorage.setItem('pw.vendors', JSON.stringify(selectedVendors())); } catch (_) {}
      }
      vendorChecks.forEach(function (cb) {
        cb.addEventListener('change', function () {
          persistVendors();
          if (cachedSource) renderSourcePalette(cachedSource.palette);
          if (cachedPairs && !catalogPanel.hidden) renderGrid(catalogGrid, cachedPairs, false);
        });
      });

      function wireSlider(input, valEl, gridEl, varName, storageKey) {
        function apply() {
          var n = Math.max(3, Math.min(12, parseInt(input.value, 10) || 8));
          valEl.textContent = n;
          gridEl.parentNode.parentNode.style.setProperty('--' + varName, n);
          root.style.setProperty('--' + varName, n);
          try { localStorage.setItem(storageKey, n); } catch (_) {}
        }
        try {
          var s = parseInt(localStorage.getItem(storageKey), 10);
          if (!isNaN(s) && s >= 3 && s <= 12) input.value = s;
        } catch (_) {}
        input.addEventListener('input', apply);
        apply();
      }
      wireSlider(catalogCols, catalogColsVal, catalogGrid, 'cols-catalog', 'pw.cols.catalog');
      wireSlider(aiCols,      aiColsVal,      aiGrid,      'cols-ai',      'pw.cols.ai');

      function esc(s) {
        return String(s == null ? '' : s)
          .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
          .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
      }
      function renderSkeletons(grid, n) {
        var html = '';
        for (var i = 0; i < n; i++) html += '<div class="pairs-well__skeleton"></div>';
        grid.innerHTML = html;
      }

      function renderSourcePalette(palette) {
        if (!palette || !palette.length) { sourcePalette.hidden = true; vendorPicker.hidden = true; return; }
        var vendors = selectedVendors();
        var head = '<th></th>' + vendors.map(function (v) { return '<th>' + esc(VENDOR_LABEL[v]) + '</th>'; }).join('');
        var body = palette.map(function (c) {
          var hex = typeof c === 'string' ? c : c.hex;
          var cells = '<td class="swatch"><span class="pw-dot" style="background:' + esc(hex) + '"></span></td>';
          vendors.forEach(function (v) {
            var p = c && c.paints && c.paints[v];
            cells += '<td>' + (p
              ? '<span class="pw-square" style="background:' + esc(p.hex) + '"></span>' + esc(p.name) + ' <span class="pw-code">' + esc(p.code) + '</span>'
              : '—') + '</td>';
          });
          return '<tr>' + cells + '</tr>';
        }).join('');
        sourcePalette.innerHTML = '<h4>Color story</h4><table><thead><tr>' + head + '</tr></thead><tbody>' + body + '</tbody></table>';
        sourcePalette.hidden = false;
        vendorPicker.hidden = false;
      }

      function renderCard(p, isAi) {
        var img = p.image_url ? esc(p.image_url) : '';
        var why = (p.why && p.why[0]) ? esc(p.why[0]) : (p.motif ? esc(p.motif) : '');
        var palette = (p.palette || []).map(function (c) {
          var hex = typeof c === 'string' ? c : c.hex;
          var sw = c && c.paints && c.paints.sw, de = c && c.paints && c.paints.de;
          var tip = (sw ? 'SW ' + sw.name + ' ' + sw.code : '') + (de ? ' · DE ' + de.name + ' ' + de.code : '');
          return '<span class="pw-dot" style="background:' + esc(hex) + '" title="' + esc(tip) + '"></span>';
        }).join('');
        var dom = (p.palette && p.palette[0]) || null;
        var paintsBlock = '';
        if (dom && dom.paints) {
          var vendors = selectedVendors();
          var rows = vendors.map(function (v) {
            var paint = dom.paints[v];
            if (!paint) return '';
            return '<span class="row"><span class="label">' + esc(VENDOR_SHORT[v]) + '</span>'
              + '<span class="pw-square" style="background:' + esc(paint.hex) + '"></span>'
              + esc(paint.name) + ' <span class="pw-code">' + esc(paint.code) + '</span></span>';
          }).filter(Boolean).join('');
          if (rows) paintsBlock = '<div class="pairs-well__paints">' + rows + '</div>';
        }
        var href = p.url ? esc(p.url) : (p.handle ? '/products/' + esc(p.handle) : '#');
        return '<a class="pairs-well__card' + (isAi ? ' pairs-well__card--ai' : '') + '" href="' + href + '" title="' + esc((p.why || []).join(' · ')) + '">'
          +   '<div class="pairs-well__img-wrap">' + (img ? '<img class="pairs-well__img" src="' + img + '" alt="' + esc(p.title) + '" loading="lazy">' : '') + '</div>'
          +   '<div class="pairs-well__meta">'
          +     '<p class="pairs-well__title">' + esc(p.title || '') + '</p>'
          +     '<p class="pairs-well__vendor">' + esc(p.vendor || (isAi ? 'Auto Generated' : '')) + '</p>'
          +     (why ? '<span class="pairs-well__why">' + why + '</span>' : '')
          +     (palette ? '<div class="pairs-well__palette">' + palette + '</div>' : '')
          +     paintsBlock
          +   '</div>'
          + '</a>';
      }
      function renderGrid(grid, items, isAi) {
        if (!items || !items.length) { grid.innerHTML = '<div class="pairs-well__empty">No coordinates found for this pattern.</div>'; return; }
        grid.innerHTML = items.map(function (p) { return renderCard(p, isAi); }).join('');
      }

      function buildUrl(base, limit) {
        var qs = [];
        // External source (wallco design, etc.) takes precedence — passes palette directly
        if (palette) {
          qs.push('palette=' + encodeURIComponent(palette));
          if (sourceTitle) qs.push('title=' + encodeURIComponent(sourceTitle));
          if (sourceImage) qs.push('image_url=' + encodeURIComponent(sourceImage));
        } else {
          if (sku)    qs.push('dw_sku=' + encodeURIComponent(sku));
          if (handle) qs.push('handle=' + encodeURIComponent(handle));
        }
        if (limit)  qs.push('limit=' + limit);
        return base + '?' + qs.join('&');
      }

      function load() {
        if (loaded) return Promise.resolve();
        loaded = true;
        renderSkeletons(catalogGrid, 6);
        renderSkeletons(aiGrid, 6);
        catalogPanel.hidden = false;
        aiPanel.hidden = false;

        var pairsPromise = fetch(buildUrl(endpointPairs, 6), { credentials: 'omit' })
          .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('http ' + r.status)); })
          .then(function (d) {
            if (!d || d.ok === false) throw new Error((d && d.error) || 'bad payload');
            cachedSource = d.source || null;
            cachedPairs  = d.pairs  || [];
            if (cachedSource) renderSourcePalette(cachedSource.palette || []);
            renderGrid(catalogGrid, cachedPairs, false);
            catalogCount.textContent = '(' + cachedPairs.length + ')';
            if (!cachedPairs.length && (!cachedSource || !cachedSource.palette || !cachedSource.palette.length)) {
              root.hidden = true;
            }
          })
          .catch(function () { catalogGrid.innerHTML = '<div class="pairs-well__empty">Could not load coordinates right now.</div>'; });

        var aiPromise = fetch(buildUrl(endpointAi, 6), { credentials: 'omit' })
          .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('http ' + r.status)); })
          .then(function (d) {
            if (!d || d.ok === false) throw new Error('bad payload');
            cachedAi = d.items || [];
            renderGrid(aiGrid, cachedAi, true);
          })
          .catch(function () { aiGrid.innerHTML = '<div class="pairs-well__empty">Auto Generated coordinates not ready.</div>'; });

        return Promise.all([pairsPromise, aiPromise]);
      }

      cta.addEventListener('click', function () {
        var open = cta.getAttribute('aria-expanded') === 'true';
        if (open) {
          cta.setAttribute('aria-expanded', 'false');
          sourcePalette.hidden = true;
          vendorPicker.hidden = true;
          catalogPanel.hidden = true;
          aiPanel.hidden = true;
          return;
        }
        cta.setAttribute('aria-expanded', 'true');
        load();
      });
    })();
  </script>