← 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
// ── 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();