[object Object]

← back to Fentucci

hero→room renders w/ readability scrim; full SEO (canonical/OG/Twitter/JSON-LD/favicons); sitemap.xml route; infinite scroll w/ sort+density+load-more fallback

9141b642a0520a136b73fe20753eb3de3a07fa0b · 2026-06-24 09:52:04 -0700 · Steve

Files touched

Diff

commit 9141b642a0520a136b73fe20753eb3de3a07fa0b
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jun 24 09:52:04 2026 -0700

    hero→room renders w/ readability scrim; full SEO (canonical/OG/Twitter/JSON-LD/favicons); sitemap.xml route; infinite scroll w/ sort+density+load-more fallback
---
 public/css/site.css                |  51 +++++--
 public/images/apple-touch-icon.png | Bin 0 -> 1456 bytes
 public/images/favicon-16.png       | Bin 0 -> 1236 bytes
 public/images/favicon-32.png       | Bin 0 -> 2093 bytes
 public/images/favicon.ico          | Bin 0 -> 5430 bytes
 public/index.html                  |  87 +++++++++++-
 public/js/app.js                   | 264 +++++++++++++++++++++++++------------
 server.js                          |  28 ++++
 8 files changed, 329 insertions(+), 101 deletions(-)

diff --git a/public/css/site.css b/public/css/site.css
index eff9cbc..5d9e0ef 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -195,15 +195,18 @@ html[data-theme="dark"]       .icon-moon { display: none; }
   z-index: 2;
   width: 100%;
   padding: 5rem 2rem 4rem;
-  background: linear-gradient(to top, rgba(20,18,14,0.72) 0%, rgba(20,18,14,0.28) 60%, transparent 100%);
+  /* Stronger scrim — the room renders vary from very light (straw/ivory) to
+     dark (jute blue), so the wordmark needs a reliable contrast floor. */
+  background: linear-gradient(to top, rgba(18,15,11,0.82) 0%, rgba(18,15,11,0.55) 38%, rgba(18,15,11,0.18) 72%, transparent 100%);
 }
 .hero-eyebrow {
   font-size: 0.72rem;
   font-weight: 500;
   letter-spacing: 0.2em;
   text-transform: uppercase;
-  color: rgba(255,255,255,0.72);
+  color: rgba(255,255,255,0.82);
   margin-bottom: 0.6rem;
+  text-shadow: 0 1px 6px rgba(0,0,0,0.45);
 }
 .hero-title {
   font-family: 'Cormorant Garamond', Georgia, serif;
@@ -213,12 +216,14 @@ html[data-theme="dark"]       .icon-moon { display: none; }
   line-height: 1;
   letter-spacing: -0.01em;
   margin-bottom: 0.5rem;
+  text-shadow: 0 2px 18px rgba(0,0,0,0.55), 0 1px 3px rgba(0,0,0,0.4);
 }
 .hero-tagline {
   font-family: 'Cormorant Garamond', Georgia, serif;
   font-size: clamp(1.1rem, 2vw, 1.5rem);
-  color: rgba(255,255,255,0.88);
+  color: rgba(255,255,255,0.92);
   margin-bottom: 2rem;
+  text-shadow: 0 1px 10px rgba(0,0,0,0.5);
 }
 .hero-ctas {
   display: flex;
@@ -454,27 +459,45 @@ html[data-theme="dark"]       .icon-moon { display: none; }
 }
 .grid-empty h3 { margin-bottom: 0.5rem; font-weight: 500; }
 
-/* ── Pagination ───────────────────────────────────────────────── */
+/* ── Pagination / infinite scroll ─────────────────────────────── */
 .pagination {
   display: flex;
+  flex-direction: column;
   justify-content: center;
   align-items: center;
   gap: 0.4rem;
   margin-top: 2.5rem;
-  flex-wrap: wrap;
 }
-.page-btn {
-  padding: 0.45rem 0.85rem;
+/* Invisible IntersectionObserver trigger near the grid bottom */
+.infinite-sentinel { width: 100%; height: 1px; }
+.load-more-btn {
+  padding: 0.7rem 2rem;
   border-radius: var(--radius-sm);
+  font-size: 0.82rem;
+  font-weight: 600;
+  letter-spacing: 0.05em;
+  text-transform: uppercase;
+  border: 1px solid var(--accent);
+  background: var(--accent-bg);
+  color: var(--accent-dark);
+  transition: background 0.15s, color 0.15s;
+}
+.load-more-btn:hover { background: var(--accent); color: #fff; }
+.infinite-loading {
+  display: flex;
+  align-items: center;
+  gap: 0.6rem;
+  color: var(--ink-soft);
   font-size: 0.85rem;
-  border: 1px solid var(--line);
-  background: var(--card-bg);
-  color: var(--ink);
-  transition: background 0.12s, border-color 0.12s;
+  padding: 1rem 0;
+}
+.spinner-sm { width: 20px; height: 20px; border-width: 2px; margin: 0; }
+.infinite-end {
+  color: var(--ink-faint);
+  font-size: 0.82rem;
+  letter-spacing: 0.04em;
+  padding: 1.25rem 0;
 }
-.page-btn:hover { background: var(--hover-bg); border-color: var(--accent); }
-.page-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
-.page-btn:disabled { opacity: 0.4; pointer-events: none; }
 
 /* ── Footer ───────────────────────────────────────────────────── */
 .site-footer {
diff --git a/public/images/apple-touch-icon.png b/public/images/apple-touch-icon.png
new file mode 100644
index 0000000..68b7d6a
Binary files /dev/null and b/public/images/apple-touch-icon.png differ
diff --git a/public/images/favicon-16.png b/public/images/favicon-16.png
new file mode 100644
index 0000000..e755439
Binary files /dev/null and b/public/images/favicon-16.png differ
diff --git a/public/images/favicon-32.png b/public/images/favicon-32.png
new file mode 100644
index 0000000..a74fdff
Binary files /dev/null and b/public/images/favicon-32.png differ
diff --git a/public/images/favicon.ico b/public/images/favicon.ico
new file mode 100644
index 0000000..3cc7851
Binary files /dev/null and b/public/images/favicon.ico differ
diff --git a/public/index.html b/public/index.html
index 06e1636..ae53738 100644
--- a/public/index.html
+++ b/public/index.html
@@ -7,14 +7,95 @@
   <script>(function(){try{var t=localStorage.getItem('fentucci-theme');if(!t){t=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
   <title>Fentucci Natural Wallcoverings — Grasscloth &amp; Natural Fiber | Designer Wallcoverings</title>
   <meta name="description" content="Explore Fentucci's collection of natural fiber and grasscloth wallcoverings, available through Designer Wallcoverings. Free memo samples. Trade service. Expert hand-holding from selection to install.">
-  <meta property="og:title" content="Fentucci Natural Wallcoverings — Designer Wallcoverings">
+  <meta name="robots" content="index, follow, max-image-preview:large">
+  <link rel="canonical" href="https://fentucci.com/">
+
+  <!-- ── Open Graph ──────────────────────────────────────────────── -->
+  <meta property="og:type" content="website">
+  <meta property="og:site_name" content="Fentucci Natural Wallcoverings">
+  <meta property="og:locale" content="en_US">
+  <meta property="og:url" content="https://fentucci.com/">
+  <meta property="og:title" content="Fentucci Natural Wallcoverings — Grasscloth &amp; Natural Fiber">
   <meta property="og:description" content="Natural fiber, grasscloth, and woven wallcoverings from Fentucci. Shop with free memo samples and DW trade service.">
+  <meta property="og:image" content="https://fentucci.com/images/hero/hero-5.v1.jpg">
+  <meta property="og:image:secure_url" content="https://fentucci.com/images/hero/hero-5.v1.jpg">
+  <meta property="og:image:type" content="image/jpeg">
+  <meta property="og:image:width" content="1344">
+  <meta property="og:image:height" content="768">
+  <meta property="og:image:alt" content="Coastal lounge with a Fentucci abaca grasscloth feature wall">
+
+  <!-- ── Twitter ─────────────────────────────────────────────────── -->
+  <meta name="twitter:card" content="summary_large_image">
+  <meta name="twitter:title" content="Fentucci Natural Wallcoverings — Grasscloth &amp; Natural Fiber">
+  <meta name="twitter:description" content="Natural fiber, grasscloth, and woven wallcoverings from Fentucci. Free memo samples &amp; DW trade service.">
+  <meta name="twitter:image" content="https://fentucci.com/images/hero/hero-5.v1.jpg">
+  <meta name="twitter:image:alt" content="Coastal lounge with a Fentucci abaca grasscloth feature wall">
+
   <meta name="theme-color" content="#faf7f2" media="(prefers-color-scheme: light)">
   <meta name="theme-color" content="#141210" media="(prefers-color-scheme: dark)">
+
+  <!-- ── Icons ───────────────────────────────────────────────────── -->
+  <link rel="icon" href="/images/favicon.ico" sizes="any">
+  <link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32.png">
+  <link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16.png">
+  <link rel="apple-touch-icon" href="/images/apple-touch-icon.png">
+
+  <!-- ── Resource hints ──────────────────────────────────────────── -->
   <link rel="preconnect" href="https://fonts.googleapis.com">
   <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+  <link rel="preconnect" href="https://cdn.shopify.com">
+  <link rel="dns-prefetch" href="https://cdn.shopify.com">
   <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400;1,600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
-  <link rel="stylesheet" href="/css/site.css">
+  <link rel="stylesheet" href="/css/site.css?v=2">
+
+  <!-- ── Structured data ─────────────────────────────────────────── -->
+  <script type="application/ld+json">
+  {
+    "@context": "https://schema.org",
+    "@graph": [
+      {
+        "@type": "Organization",
+        "@id": "https://fentucci.com/#organization",
+        "name": "Fentucci Natural Wallcoverings",
+        "url": "https://fentucci.com/",
+        "logo": "https://fentucci.com/images/apple-touch-icon.png",
+        "image": "https://fentucci.com/images/hero/hero-5.v1.jpg",
+        "description": "Natural fiber and grasscloth wallcoverings, available through Designer Wallcoverings.",
+        "parentOrganization": {
+          "@type": "Organization",
+          "name": "Designer Wallcoverings",
+          "url": "https://designerwallcoverings.com/"
+        }
+      },
+      {
+        "@type": "WebSite",
+        "@id": "https://fentucci.com/#website",
+        "url": "https://fentucci.com/",
+        "name": "Fentucci Natural Wallcoverings",
+        "publisher": { "@id": "https://fentucci.com/#organization" },
+        "potentialAction": {
+          "@type": "SearchAction",
+          "target": {
+            "@type": "EntryPoint",
+            "urlTemplate": "https://fentucci.com/?q={search_term_string}"
+          },
+          "query-input": "required name=search_term_string"
+        }
+      },
+      {
+        "@type": "BreadcrumbList",
+        "@id": "https://fentucci.com/#breadcrumb",
+        "itemListElement": [
+          { "@type": "ListItem", "position": 1, "name": "Designer Wallcoverings", "item": "https://designerwallcoverings.com/" },
+          { "@type": "ListItem", "position": 2, "name": "Fentucci Natural Wallcoverings", "item": "https://fentucci.com/" }
+        ]
+      }
+    ]
+  }
+  </script>
+  <!-- ItemList of the catalog is injected at runtime from /api/products into #ld-itemlist -->
+  <script type="application/ld+json" id="ld-itemlist">{"@context":"https://schema.org","@type":"ItemList","name":"Fentucci Natural Wallcoverings Catalog","url":"https://fentucci.com/","numberOfItems":0,"itemListElement":[]}</script>
+
   <!-- GA4 placeholder — inject measurement ID when provisioned -->
   <!-- <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script> -->
 </head>
@@ -167,6 +248,6 @@
   </div>
 </div>
 
-<script src="/js/app.js"></script>
+<script src="/js/app.js?v=2"></script>
 </body>
 </html>
diff --git a/public/js/app.js b/public/js/app.js
index dbe6abe..29a70dc 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -42,33 +42,46 @@ 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 a handful of products to populate the hero
-  fetch('/api/products?per_page=12&sort=newest')
+
+  fetch('/images/hero/manifest.json')
     .then(r => r.json())
     .then(data => {
-      const imgs = (data.products || [])
-        .filter(p => p.image_url)
-        .slice(0, 7);
+      const imgs = (data.images || []).filter(x => x.src);
       if (!imgs.length) return;
       heroBg.classList.add('has-images');
-      imgs.forEach((p, i) => {
+      imgs.forEach((it, i) => {
         const el = document.createElement('img');
-        el.src = p.image_url;
-        el.alt = p.title || '';
-        el.loading = 'lazy';
+        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;
-      setInterval(() => {
-        const all = heroBg.querySelectorAll('img');
+      let timer = setInterval(advance, 5500);
+      function advance() {
         all[cur].classList.remove('active');
         cur = (cur + 1) % all.length;
         all[cur].classList.add('active');
-      }, 4500);
+      }
+      // 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 */});
 })();
@@ -78,9 +91,8 @@ if (sortEl) {
   sortEl.value = state.sort;
   sortEl.addEventListener('change', () => {
     state.sort = sortEl.value;
-    state.page = 1;
     localStorage.setItem('fentucci-sort', state.sort);
-    loadProducts();
+    resetAndLoad();
   });
 }
 if (densityEl) {
@@ -103,26 +115,44 @@ if (searchEl) {
     clearTimeout(searchTimer);
     searchTimer = setTimeout(() => {
       state.q = searchEl.value.trim();
-      state.page = 1;
-      loadProducts();
+      resetAndLoad();
     }, 280);
   });
 }
 
-// ── Fetch + render ──────────────────────────────────────────────
-function loadProducts() {
-  if (state.loading) return;
-  state.loading = true;
-
+// ── 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>';
-  pagination.innerHTML = '';
   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: state.page,
-    per_page: 60,
+    page: nextPage,
+    per_page: PER_PAGE,
     q: state.q
   });
 
@@ -130,38 +160,105 @@ function loadProducts() {
     .then(r => r.json())
     .then(data => {
       state.loading = false;
-      renderGrid(data);
+      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;
-      grid.innerHTML = '<div class="grid-empty"><h3>Error loading catalog</h3><p>Please refresh the page.</p></div>';
       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');
+      }
     });
 }
 
-function renderGrid(data) {
-  const { total, page, pages, products } = data;
-
-  // meta
-  if (countEl) countEl.textContent = `${total.toLocaleString()} product${total !== 1 ? 's' : ''}`;
-  if (pageInfoEl && pages > 1) pageInfoEl.textContent = `Page ${page} of ${pages}`;
-
-  // empty
-  if (!products || !products.length) {
-    grid.innerHTML = '<div class="grid-empty"><h3>No products found</h3><p>Try a different search or sort.</p></div>';
-    return;
+// ── 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;
   }
+}
 
-  // cards
-  grid.innerHTML = products.map(p => cardHTML(p)).join('');
-
-  // pagination
-  renderPagination(page, pages);
+function wireLoadMore() {
+  const btn = document.getElementById('load-more-btn');
+  if (btn) btn.addEventListener('click', () => loadNextPage(false));
+}
 
-  // scroll to catalog (only on page change, not initial load)
-  if (state.page > 1) {
-    document.getElementById('catalog').scrollIntoView({ behavior: 'smooth', block: 'start' });
-  }
+// 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) {
@@ -199,43 +296,6 @@ function esc(str) {
   return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
 }
 
-function renderPagination(page, pages) {
-  if (pages <= 1) { pagination.innerHTML = ''; return; }
-
-  const maxVisible = 7;
-  let html = '';
-
-  html += `<button class="page-btn" ${page===1?'disabled':''} data-page="${page-1}" aria-label="Previous page">&larr; Prev</button>`;
-
-  // page numbers
-  const half = Math.floor(maxVisible / 2);
-  let start = Math.max(1, page - half);
-  let end   = Math.min(pages, start + maxVisible - 1);
-  if (end - start < maxVisible - 1) start = Math.max(1, end - maxVisible + 1);
-
-  if (start > 1) {
-    html += `<button class="page-btn" data-page="1">1</button>`;
-    if (start > 2) html += `<span class="page-btn" style="pointer-events:none;opacity:.4">…</span>`;
-  }
-  for (let i = start; i <= end; i++) {
-    html += `<button class="page-btn${i===page?' active':''}" data-page="${i}"${i===page?' aria-current="page"':''}>${i}</button>`;
-  }
-  if (end < pages) {
-    if (end < pages - 1) html += `<span class="page-btn" style="pointer-events:none;opacity:.4">…</span>`;
-    html += `<button class="page-btn" data-page="${pages}">${pages}</button>`;
-  }
-
-  html += `<button class="page-btn" ${page===pages?'disabled':''} data-page="${page+1}" aria-label="Next page">Next &rarr;</button>`;
-
-  pagination.innerHTML = html;
-  pagination.querySelectorAll('[data-page]').forEach(btn => {
-    btn.addEventListener('click', () => {
-      state.page = parseInt(btn.dataset.page, 10);
-      loadProducts();
-    });
-  });
-}
-
 // ── Sample request modal ────────────────────────────────────────
 (function () {
   const modal     = document.getElementById('sample-modal');
@@ -315,5 +375,41 @@ function renderPagination(page, pages) {
   }
 })();
 
+// ── 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 ────────────────────────────────────────────────────────
-loadProducts();
+resetAndLoad();
diff --git a/server.js b/server.js
index 533130f..ab9095a 100644
--- a/server.js
+++ b/server.js
@@ -132,6 +132,34 @@ app.get('/api/facets', (req, res) => {
 // ── API: health ───────────────────────────────────────────────────────────────
 app.get('/health', (req, res) => res.json({ ok: true, products: catalog.length, ts: new Date().toISOString() }));
 
+// ── SEO: sitemap.xml ────────────────────────────────────────────────────────
+// Homepage + a deep-link per catalog product (search anchor on the DW store,
+// where the products actually live). Built once from the loaded catalog.
+const SITE_ORIGIN = process.env.SITE_ORIGIN || 'https://fentucci.com';
+const DW_ORIGIN   = 'https://designerwallcoverings.com';
+let _sitemapCache = null;
+function buildSitemap() {
+  const today = new Date().toISOString().slice(0, 10);
+  const xmlEsc = s => String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&apos;');
+  const urls = [];
+  // Homepage (highest priority)
+  urls.push(`  <url><loc>${SITE_ORIGIN}/</loc><lastmod>${today}</lastmod><changefreq>daily</changefreq><priority>1.0</priority></url>`);
+  // One entry per product → its real DW product page
+  const seen = new Set();
+  catalog.forEach(p => {
+    if (!p.handle || seen.has(p.handle)) return;
+    seen.add(p.handle);
+    const loc = `${DW_ORIGIN}/products/${xmlEsc(p.handle)}`;
+    urls.push(`  <url><loc>${loc}</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>`);
+  });
+  return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>\n`;
+}
+app.get('/sitemap.xml', (req, res) => {
+  if (!_sitemapCache) _sitemapCache = buildSitemap();
+  res.set('Content-Type', 'application/xml; charset=utf-8');
+  res.send(_sitemapCache);
+});
+
 // ── API: sample request email ─────────────────────────────────────────────────
 app.post('/api/send-sample-request', (req, res) => {
   const { name, email, company, address, city, state, zip, message } = req.body;

← 0be89e6 add 6 large room-setting hero renders (1344x768, Gemini 2.5  ·  back to Fentucci  ·  hero ghost-CTA legibility fix (white on dark renders) + css 0697743 →