← 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
M public/css/site.cssA public/images/apple-touch-icon.pngA public/images/favicon-16.pngA public/images/favicon-32.pngA public/images/favicon.icoM public/index.htmlM public/js/app.jsM server.js
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 & 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 & 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 & Natural Fiber">
+ <meta name="twitter:description" content="Natural fiber, grasscloth, and woven wallcoverings from Fentucci. Free memo samples & 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
-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">← 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 →</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
+ 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 →