← back to Fentucci

public/js/app.js

416 lines

/* fentucci.com — storefront app */
'use strict';

// ── State ──────────────────────────────────────────────────────
const state = {
  page: 1,
  sort: localStorage.getItem('fentucci-sort') || 'newest',
  density: parseInt(localStorage.getItem('fentucci-density') || '4', 10),
  q: '',
  loading: false
};

// ── DOM refs ───────────────────────────────────────────────────
const grid       = document.getElementById('product-grid');
const pagination = document.getElementById('pagination');
const countEl    = document.getElementById('results-count');
const pageInfoEl = document.getElementById('page-info');
const searchEl   = document.getElementById('search-input');
const sortEl     = document.getElementById('sort-select');
const densityEl  = document.getElementById('density-slider');

// ── Dark/light toggle ──────────────────────────────────────────
(function () {
  const btn = document.getElementById('theme-toggle');
  if (!btn) return;
  function sync() {
    const t = document.documentElement.getAttribute('data-theme');
    btn.setAttribute('aria-pressed', t === 'dark' ? 'true' : 'false');
  }
  sync();
  btn.addEventListener('click', function () {
    const cur = document.documentElement.getAttribute('data-theme') || 'light';
    const nxt = cur === 'dark' ? 'light' : 'dark';
    document.documentElement.setAttribute('data-theme', nxt);
    try { localStorage.setItem('fentucci-theme', nxt); } catch (e) {}
    sync();
  });
})();

// ── Year ────────────────────────────────────────────────────────
const yearEl = document.getElementById('year');
if (yearEl) yearEl.textContent = new Date().getFullYear();

// ── Hero rotation ───────────────────────────────────────────────
// Uses large, photorealistic room-setting renders (1344×768) that genuinely
// feature Fentucci grasscloth/natural-fiber patterns on the wall. These are
// freshly rendered at hero scale — NOT upscaled product thumbnails. Shown
// with background-size:cover (crops/shrinks only).
(function () {
  const heroBg = document.getElementById('hero-bg');
  if (!heroBg) return;

  fetch('/images/hero/manifest.json')
    .then(r => r.json())
    .then(data => {
      const imgs = (data.images || []).filter(x => x.src);
      if (!imgs.length) return;
      heroBg.classList.add('has-images');
      imgs.forEach((it, i) => {
        const el = document.createElement('img');
        el.src = it.src;
        el.alt = it.alt || 'Fentucci natural-fiber wallcovering room setting';
        el.width = it.w || 1344;
        el.height = it.h || 768;
        el.loading = i === 0 ? 'eager' : 'lazy';
        el.decoding = 'async';
        if (i === 0) el.classList.add('active');
        heroBg.appendChild(el);
      });

      const all = heroBg.querySelectorAll('img');
      if (all.length < 2) return;
      let cur = 0;
      let timer = setInterval(advance, 5500);
      function advance() {
        all[cur].classList.remove('active');
        cur = (cur + 1) % all.length;
        all[cur].classList.add('active');
      }
      // Pause rotation when the tab is hidden (battery / perf friendly)
      document.addEventListener('visibilitychange', () => {
        if (document.hidden) { clearInterval(timer); timer = null; }
        else if (!timer) { timer = setInterval(advance, 5500); }
      });
    })
    .catch(() => {/* gradient fallback already in CSS */});
})();

// ── Controls init ───────────────────────────────────────────────
if (sortEl) {
  sortEl.value = state.sort;
  sortEl.addEventListener('change', () => {
    state.sort = sortEl.value;
    localStorage.setItem('fentucci-sort', state.sort);
    resetAndLoad();
  });
}
if (densityEl) {
  densityEl.value = state.density;
  applyDensity(state.density);
  densityEl.addEventListener('input', () => {
    state.density = parseInt(densityEl.value, 10);
    localStorage.setItem('fentucci-density', state.density);
    applyDensity(state.density);
  });
}
function applyDensity(n) {
  document.documentElement.style.setProperty('--cols', Math.max(1, Math.min(8, n)));
}

// ── Search ──────────────────────────────────────────────────────
let searchTimer;
if (searchEl) {
  searchEl.addEventListener('input', () => {
    clearTimeout(searchTimer);
    searchTimer = setTimeout(() => {
      state.q = searchEl.value.trim();
      resetAndLoad();
    }, 280);
  });
}

// ── Infinite scroll: fetch + append ─────────────────────────────
const PER_PAGE = 60;
state.pages = 1;
state.total = 0;
state.exhausted = false;

// Reset to page 1 and re-stream — used when sort / search / (re)load changes.
function resetAndLoad() {
  state.page = 0;            // loadNextPage() pre-increments to 1
  state.pages = 1;
  state.total = 0;
  state.exhausted = false;
  state.loading = false;
  grid.innerHTML = '<div class="grid-loading" aria-label="Loading products"><div class="spinner"></div><p>Loading…</p></div>';
  if (countEl) countEl.textContent = '';
  if (pageInfoEl) pageInfoEl.textContent = '';
  setSentinelState('idle');
  loadNextPage(true);
}

// Fetch the next page and APPEND its cards (or replace the loading state on
// the first page). `first` clears the grid before inserting.
function loadNextPage(first) {
  if (state.loading || state.exhausted) return;
  state.loading = true;

  const nextPage = state.page + 1;
  if (!first) setSentinelState('loading');

  const params = new URLSearchParams({
    sort: state.sort,
    page: nextPage,
    per_page: PER_PAGE,
    q: state.q
  });

  fetch('/api/products?' + params)
    .then(r => r.json())
    .then(data => {
      state.loading = false;
      state.page = data.page || nextPage;
      state.pages = data.pages || 1;
      state.total = data.total || 0;

      // meta
      if (countEl) countEl.textContent = `${state.total.toLocaleString()} product${state.total !== 1 ? 's' : ''}`;

      const products = data.products || [];

      if (first) {
        if (!products.length) {
          grid.innerHTML = '<div class="grid-empty"><h3>No products found</h3><p>Try a different search or sort.</p></div>';
          state.exhausted = true;
          setSentinelState('hidden');
          if (pageInfoEl) pageInfoEl.textContent = '';
          return;
        }
        grid.innerHTML = products.map(p => cardHTML(p)).join('');
      } else if (products.length) {
        grid.insertAdjacentHTML('beforeend', products.map(p => cardHTML(p)).join(''));
      }

      // showing X of Y
      const shown = Math.min(state.page * PER_PAGE, state.total);
      if (pageInfoEl) pageInfoEl.textContent = state.total ? `Showing ${shown.toLocaleString()} of ${state.total.toLocaleString()}` : '';

      // last page?
      if (state.page >= state.pages) {
        state.exhausted = true;
        setSentinelState('end');
      } else {
        setSentinelState('idle');
      }
    })
    .catch(err => {
      state.loading = false;
      console.error('[app] fetch error:', err);
      if (first) {
        grid.innerHTML = '<div class="grid-empty"><h3>Error loading catalog</h3><p>Please refresh the page.</p></div>';
      } else {
        setSentinelState('error');
      }
    });
}

// ── Sentinel + Load-more fallback ───────────────────────────────
// The sentinel sits at the bottom of the grid; when it scrolls into view the
// IntersectionObserver triggers the next page. The "Load more" button is a
// no-JS-IntersectionObserver / keyboard fallback that does the same thing.
function setSentinelState(s) {
  if (!pagination) return;
  switch (s) {
    case 'idle':
      pagination.innerHTML = '<div class="infinite-sentinel" id="infinite-sentinel" aria-hidden="true"></div>' +
        '<button type="button" class="load-more-btn" id="load-more-btn">Load more</button>';
      wireLoadMore();
      break;
    case 'loading':
      pagination.innerHTML = '<div class="infinite-sentinel" id="infinite-sentinel" aria-hidden="true"></div>' +
        '<div class="infinite-loading" role="status" aria-live="polite"><div class="spinner spinner-sm"></div><span>Loading more…</span></div>';
      break;
    case 'end':
      pagination.innerHTML = '<p class="infinite-end" role="status">You’ve reached the end of the collection.</p>';
      break;
    case 'error':
      pagination.innerHTML = '<button type="button" class="load-more-btn" id="load-more-btn">Retry — load more</button>';
      wireLoadMore();
      break;
    case 'hidden':
    default:
      pagination.innerHTML = '';
      break;
  }
}

function wireLoadMore() {
  const btn = document.getElementById('load-more-btn');
  if (btn) btn.addEventListener('click', () => loadNextPage(false));
}

// IntersectionObserver watches the (re-created) sentinel via the pagination
// container as the persistent root target.
let io;
if ('IntersectionObserver' in window) {
  io = new IntersectionObserver(entries => {
    for (const e of entries) {
      if (e.isIntersecting && !state.loading && !state.exhausted) {
        loadNextPage(false);
      }
    }
  }, { root: null, rootMargin: '600px 0px', threshold: 0 });

  // Observe the sentinel whenever it (re)appears — use a MutationObserver on
  // the pagination container so the IO target survives innerHTML swaps.
  const mo = new MutationObserver(() => {
    const sentinel = document.getElementById('infinite-sentinel');
    if (sentinel) { io.disconnect(); io.observe(sentinel); }
  });
  if (pagination) mo.observe(pagination, { childList: true });
}

function cardHTML(p) {
  const dw_handle = p.handle || '';
  const shopUrl = `https://designerwallcoverings.com/products/${dw_handle}`;
  const sampleUrl = `${shopUrl}#sample`;
  const price = parseFloat(p.price);
  const priceStr = price && price > 0
    ? (price === 4.25 ? '<span class="sample-price">$4.25 sample</span>' : `From $${price.toFixed(2)}`)
    : '';

  const img = p.image_url
    ? `<img class="card-img" src="${esc(p.image_url)}" alt="${esc(p.title || '')}" loading="lazy" width="400" height="533">`
    : `<div class="card-img-placeholder">No image</div>`;

  const sku = p.dw_sku || p.mfr_sku || '';

  return `<article class="product-card" role="listitem">
  <a href="${esc(shopUrl)}" target="_blank" rel="noopener noreferrer" class="card-image-wrap" tabindex="-1" aria-label="${esc(p.title || 'Product')}">
    ${img}
  </a>
  <div class="card-body">
    <a href="${esc(shopUrl)}" target="_blank" rel="noopener noreferrer" class="card-title">${esc(p.title || '')}</a>
    ${sku ? `<span class="card-sku">${esc(sku)}</span>` : ''}
    ${priceStr ? `<div class="card-price">${priceStr}</div>` : ''}
  </div>
  <div class="card-actions">
    <a href="${esc(sampleUrl)}" target="_blank" rel="noopener noreferrer" class="card-btn card-btn-sample" title="Order a free memo sample of ${esc(p.title || 'this product')}">Order Memo Sample (free)</a>
    <a href="${esc(shopUrl)}" target="_blank" rel="noopener noreferrer" class="card-btn card-btn-shop">Shop on DW</a>
  </div>
</article>`;
}

function esc(str) {
  return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

// ── Sample request modal ────────────────────────────────────────
(function () {
  const modal     = document.getElementById('sample-modal');
  const closeBtn  = document.getElementById('modal-close');
  const form      = document.getElementById('sample-form');
  const statusEl  = document.getElementById('form-status');
  const submitBtn = document.getElementById('form-submit');
  const titleHid  = document.getElementById('modal-product-title');

  if (!modal) return;

  function openModal(productTitle) {
    if (titleHid) titleHid.value = productTitle || '';
    const h2 = modal.querySelector('#modal-title');
    if (h2) h2.textContent = productTitle ? `Order a Free Memo Sample — ${productTitle}` : 'Order a Free Memo Sample';
    modal.removeAttribute('hidden');
    document.body.style.overflow = 'hidden';
    modal.querySelector('input[name="name"]')?.focus();
    if (statusEl) { statusEl.textContent = ''; statusEl.className = 'form-status'; }
  }
  function closeModal() {
    modal.setAttribute('hidden', '');
    document.body.style.overflow = '';
  }

  if (closeBtn) closeBtn.addEventListener('click', closeModal);
  modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
  document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modal.hasAttribute('hidden')) closeModal(); });

  // expose for card clicks
  window.__openSampleModal = openModal;

  if (form) {
    form.addEventListener('submit', e => {
      e.preventDefault();
      const name  = form.querySelector('[name="name"]').value.trim();
      const email = form.querySelector('[name="email"]').value.trim();
      if (!name || !email) {
        if (statusEl) { statusEl.textContent = 'Please fill in your name and email.'; statusEl.className = 'form-status error'; }
        return;
      }
      if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Sending…'; }
      if (statusEl) { statusEl.textContent = ''; statusEl.className = 'form-status'; }

      const payload = {
        name, email,
        company:  form.querySelector('[name="company"]')?.value.trim() || '',
        address:  form.querySelector('[name="address"]')?.value.trim() || '',
        city:     form.querySelector('[name="city"]')?.value.trim() || '',
        state:    form.querySelector('[name="state"]')?.value.trim() || '',
        zip:      form.querySelector('[name="zip"]')?.value.trim() || '',
        message:  form.querySelector('[name="message"]')?.value.trim() || '',
        product:  titleHid?.value || ''
      };

      fetch('/api/send-sample-request', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload)
      })
        .then(r => r.json())
        .then(data => {
          if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Send Sample Request'; }
          if (data.success) {
            if (statusEl) { statusEl.textContent = 'Your sample request has been sent. We\'ll follow up shortly.'; statusEl.className = 'form-status success'; }
            form.reset();
            setTimeout(closeModal, 3500);
          } else {
            if (statusEl) { statusEl.textContent = data.error || 'Send failed — please try again.'; statusEl.className = 'form-status error'; }
          }
        })
        .catch(() => {
          if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Send Sample Request'; }
          if (statusEl) { statusEl.textContent = 'Network error — please try again.'; statusEl.className = 'form-status error'; }
        });
    });
  }
})();

// ── SEO: inject a real ItemList of the catalog (first page) ─────
// Emits an ItemList of Products (name / image / sku / url + brand). Prices are
// not meaningful for this catalog ($4.25 sample placeholders / null), so per
// DW rules we OMIT offers rather than emit a misleading price.
(function injectItemListLD() {
  const el = document.getElementById('ld-itemlist');
  if (!el) return;
  fetch('/api/products?per_page=120&sort=newest')
    .then(r => r.json())
    .then(data => {
      const products = data.products || [];
      const items = products.map((p, i) => {
        const url = `https://designerwallcoverings.com/products/${p.handle || ''}`;
        const node = {
          '@type': 'Product',
          'name': p.title || p.pattern_name || 'Fentucci Wallcovering',
          'sku': p.dw_sku || p.mfr_sku || undefined,
          'image': p.image_url || undefined,
          'url': url,
          'brand': { '@type': 'Brand', 'name': 'Fentucci' }
        };
        Object.keys(node).forEach(k => node[k] === undefined && delete node[k]);
        return { '@type': 'ListItem', 'position': i + 1, 'item': node };
      });
      el.textContent = JSON.stringify({
        '@context': 'https://schema.org',
        '@type': 'ItemList',
        'name': 'Fentucci Natural Wallcoverings Catalog',
        'url': 'https://fentucci.com/',
        'numberOfItems': data.total || items.length,
        'itemListElement': items
      });
    })
    .catch(() => {/* keep the empty placeholder */});
})();

// ── Boot ────────────────────────────────────────────────────────
resetAndLoad();