← back to Dw Domain Fleet

shared/render.js

984 lines

/**
 * render.js — server-rendered HTML for a fleet microsite.
 * All pages share one template; per-domain config drives theme/copy/hero.
 */
const crypto = require('crypto');
const { dominantHex } = require('./sort');
// Canonical vendor-tag predicate — single source of truth shared with the
// API-redact middleware. scrubTags() routes through this so the fleet can never
// drift to a stale SUBSET of the denylist again (the 2026-06-03 leak: render.js
// kept its own narrower list and leaked Anna French / Christian Lacroix / Ralph
// Lauren / Laura Ashley / Mind the Gap chips + Color:/Series:/Type metadata that
// the canonical isVendorTag already drops). DTD verdict A (Claude+Codex 2/2).
const { isVendorTag } = require('../../_shared/api-vendor-redact');

/* imgToken / proxyImg — vendor-neutral image URL.
 * The raw Shopify CDN image_url embeds the 3rd-party vendor name in the
 * filename (e.g. WolfGordonWallcovering_DWWG_em9505.jpg). Emitting it in a card
 * data-img / background-image / hero / JSON-LD leaks the vendor (settlement
 * vendor-confidentiality). The fix is a server-side image proxy: render emits
 * /img/<token>, and server.js resolves token -> real CDN url from a boot map and
 * streams the bytes, so the vendor never appears in the DOM OR the network tab
 * (DTD 2026-06-03 verdict A — streaming proxy, vote 2/2, Qwen non-vote).
 *
 * imgToken is a PURE deterministic hash of the url string — render.js stays
 * stateless (no POOL access). server.js builds its token->url map with this same
 * function so the two sides agree. sha1 here is an opaque short id, NOT a
 * security primitive; 20 hex chars = 80 bits, ample for a per-site catalog. */
function imgToken(url) {
  if (!url) return '';
  const ext = (String(url).match(/\.(jpe?g|png|webp|gif|avif)(?:[?#]|$)/i) || [null, 'jpg'])[1].toLowerCase();
  const h = crypto.createHash('sha1').update(String(url)).digest('hex').slice(0, 20);
  return h + '.' + (ext === 'jpeg' ? 'jpg' : ext);
}
function proxyImg(url) {
  const t = imgToken(url);
  return t ? '/img/' + t : '';
}

function esc(s) {
  return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
    ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
function clean(s) {
  // strip the banned word "Wallpaper" → "Wallcovering" in any rendered copy
  return String(s || '').replace(/wallpapers/gi, 'Wallcoverings').replace(/wallpaper/gi, 'Wallcovering');
}
/* productSlug — the fleet's OWN /product/ route key.
 * Derived from the Shopify handle with "wallpaper" → "wallcovering" so a fleet
 * URL never carries the off-brand word. The real `handle` is left UNTOUCHED on
 * the product object — it stays the join key for the rel=canonical tag and the
 * buy-sample CTA that point at the live designerwallcoverings.com store. Pure
 * and deterministic: the route can recompute it without storing anything.
 */
function productSlug(p) {
  const h = (p && p.handle) || '';
  let s = scrubVendor(h).replace(/wallpapers/gi, 'wallcoverings').replace(/wallpaper/gi, 'wallcovering')
    .replace(/\s+/g, '-');   // scrubVendor leaves a space where each vendor token was — re-hyphenate for a clean slug
  s = s.replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
  if (s && !hasVendorToken(s)) return s;
  // Reaching here means scrubVendor either EMPTIED the handle (whole handle was a
  // vendor, e.g. "brunschwig-fils") OR left a CONCATENATED vendor token its
  // separator-requiring regex can't catch (e.g. "wolfgordonwallcovering" — no
  // separator between wolf+gordon, so \bword[^a-z0-9]+word\b never matches). Either
  // way, do NOT return the raw/dirty handle (re-leaks the vendor in the URL).
  // Derive a clean, deterministic, vendor-free slug: scrubbed sku if usable AND
  // itself vendor-free, else a stable hash of the raw handle. The route recomputes
  // the same productSlug AND still resolves old /product/<raw-handle> links via x.handle.
  const skuSlug = scrubVendor(String((p && p.sku) || '').toLowerCase())
    .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
  if (skuSlug && !hasVendorToken(skuSlug)) return skuSlug;
  let hash = 0;
  for (let i = 0; i < h.length; i++) hash = (hash * 31 + h.charCodeAt(i)) >>> 0;
  return 'design-' + hash.toString(36);
}

/* scrubTags — drop any tag carrying a 3rd-party vendor token (or internal
 * Color:/Series:/Type/Collection metadata) before rendering. Tag-chip surfaces
 * (the data-tags attribute on cards + the productPage <span> chip strip) were
 * leaking vendor names straight from p.tags[] to the rendered DOM. This filter
 * runs at the render boundary so both the data-tags attribute and the
 * productPage chips see the scrubbed list.
 *
 * 2026-06-03 (DTD verdict A): scrubTags now delegates to the CANONICAL
 * isVendorTag() in _shared/api-vendor-redact.js — the same predicate the API
 * middleware uses — instead of a private, narrower copy. Maintaining a second
 * list here caused subset drift: the local list lagged the canonical one and
 * leaked Anna French / Christian Lacroix / Ralph Lauren / Laura Ashley /
 * Mind the Gap / Dolce & Gabbana / A.S. Création chips, plus internal Color:/
 * Series:/Type metadata chips. One source of truth ⇒ no more drift. House
 * brands (Designer Wallcoverings, Hollywood, Phillipe Romano, Retro Walls,
 * Maison Lustre) are still NEVER scrubbed — isVendorTag keeps them.
 *
 * The VENDORS_DENYLIST / scrubVendor / hasVendorToken trio below is a DIFFERENT
 * operation (in-place vendor-token removal from a displayed title and the
 * /product URL slug) that isVendorTag does not expose, so it stays. */
const VENDORS_DENYLIST = [
  'wolf gordon', 'nina campbell', 'jeffrey stevens', 'versace',
  'arte international', 'lee jofa modern', 'lee jofa', 'innovations usa',
  'innovations', 'koroseal', 'schumacher', 'candice olson', 'thibaut',
  'maya romanoff', 'scalamandre', 'dedar', 'carnegie', 'cole and son',
  'kravet', 'clarke and clarke', 'brunschwig', 'gp j baker', 'sandberg',
  'designers guild', 'graham and brown', 'harlequin', 'andrew martin',
  'ronald redding', 'blithfield', 'coordonne', 'fentucci', 'sister parish',
  'phillip jeffries', 'fromental', 'westport', 'breegan', 'les ensembliers',
  'roger thomas', 'stacy garcia', 'de gournay', 'romo', 'farrow and ball',
  'colefax and fowler', 'sanderson', 'morris and co', 'osborne and little',
  // 2026-06-03: vendors the canonical isVendorTag (api-vendor-redact) carries
  // but scrubVendor's slug/title stripper was missing — they leaked into the
  // /product URL slug + data-handle (e.g. ".../as-creation", ".../gaston-y-daniela").
  // Tag-chips are now handled by isVendorTag; these keep scrubVendor in lockstep
  // so the URL slug + visible title never carry a 3rd-party vendor either.
  'anna french', 'christian lacroix', 'ralph lauren', 'laura ashley',
  'mind the gap', 'as creation', 'a s creation', 'gaston y daniela',
  'brand mckenzie', 'dolce and gabbana', 'dolce gabbana', 'versa designed',
  'pierre frey', 'clarence house', 'donghia', 'elitis', 'mulberry', 'nobilis',
  'phyllis morris', 'scion', 'zoffany', 'marimekko', 'missoni', 'roberto cavalli',
  'kelly wearstler', 'winfield thybony', 'villa nova', 'wallquest', 'vahallan',
  'lincrusta', 'newmor', 'china seas', 'fabricut', 'baker lifestyle',
];
const VENDORS_DENYLIST_NORM = VENDORS_DENYLIST.map(s =>
  String(s).toLowerCase().replace(/[^a-z0-9]+/g, '')
);
function hasVendorToken(s) {
  const n = String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]+/g, '');
  if (!n) return false;
  for (let i = 0; i < VENDORS_DENYLIST_NORM.length; i++) {
    if (n.indexOf(VENDORS_DENYLIST_NORM[i]) >= 0) return true;
  }
  return false;
}
function scrubTags(tags) {
  if (!Array.isArray(tags)) return [];
  // Canonical predicate (drops vendors + internal metadata tags, keeps house
  // brands). Belt-and-suspenders: also apply the local slug-oriented
  // hasVendorToken so any vendor present in scrubVendor's list but momentarily
  // absent from the canonical one is still caught.
  return tags.filter(t => t && !isVendorTag(t) && !hasVendorToken(t));
}

/* scrubVendor — REMOVE any 3rd-party vendor name from a DISPLAYED string
 * (product title, the visible sku/handle in card-meta, and the /product slug).
 * Where scrubTags drops a whole tag, this strips just the vendor token in place.
 * Longest-match-first so "lee jofa modern" beats "lee jofa"; separators are
 * flexible (\b…[^a-z0-9]+…\b) so it catches "Brunschwig & Fils" (title) AND
 * "brunschwig-fils" (slug). House brands aren't on the denylist → untouched.
 * SAFE for slugs: server.js resolves /product/:handle by raw handle OR
 * productSlug(x), and the buy-sample CTA uses the untouched p.handle — so
 * cleaning the slug never breaks a product link. (2026-05-29 fleet vendor-leak
 * audit: slug/handle + visible card-title vector.) */
const _VENDOR_SCRUB = VENDORS_DENYLIST
  .concat(['brunschwig fils', 'lee jofa modern', 'gp j baker', 'g p j baker',
           'cole and son', 'cole son', 'clarke and clarke', 'clarke clarke',
           'morris and co', 'morris co', 'caroline cecil textiles', 'caroline cecil'])
  .slice().sort((a, b) => b.length - a.length)
  // Split on non-alphanumeric runs, drop empties, join with an OPTIONAL separator
  // ([^a-z0-9]* — zero-or-more) so each vendor matches BOTH the separated form
  // ("Brunschwig & Fils", "wolf-gordon") AND the concatenated/no-separator
  // handle spelling ("wolfgordon", "gpjbaker") — the 936 wolfgordon URL-slug
  // leaks the 2026-05-30 reaudit found. \b boundaries keep it from over-matching
  // inside unrelated words. Tokens <2 chars are kept literal-only (no optional
  // sep on a single letter would over-match), so multi-initial vendors use the
  // explicit "g p j baker" variant above.
  .map(v => new RegExp('\\b' + v.split(/[^a-z0-9]+/i).filter(Boolean).join('[^a-z0-9]*') + '\\b', 'ig'));
function scrubVendor(s) {
  if (s == null) return s;
  let o = String(s);
  for (let i = 0; i < _VENDOR_SCRUB.length; i++) o = o.replace(_VENDOR_SCRUB[i], ' ');
  return o
    .replace(/\s{2,}/g, ' ')
    .replace(/(\s*-\s*)+/g, '-')   // collapse mid-string residue like "x- -1" → "x-1"
    .replace(/-{2,}/g, '-')
    .replace(/^[\s\-–—|·,&]+|[\s\-–—|·,&]+$/g, '')
    .replace(/\s{2,}/g, ' ')
    .trim();
}

const DW = 'https://designerwallcoverings.com';

/* ---------- shared CSS ---------- */
function css(cfg) {
  const t = cfg.theme;
  return `
:root{
  --bg:${t.bgLight};--fg:${t.inkLight};--muted:#7c736a;--rule:${t.rule||'#d8cec0'};
  --accent:${t.accent};--card:#ffffff;--header-bg:${t.bgLight};
  --serif:${t.serif||"'Cormorant Garamond','Playfair Display',Georgia,serif"};
  --sans:${t.sans||"-apple-system,'Inter',system-ui,sans-serif"};
  --cols:5;
}
[data-theme=dark]{
  --bg:${t.bg};--fg:${t.ink};--muted:#9a9088;--rule:#2c2620;--card:#16130f;--header-bg:${t.bg};
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.5;
  transition:background .3s,color .3s}
a{color:inherit;text-decoration:none}
img{display:block;max-width:100%}
.wrap{max-width:1480px;margin:0 auto;padding:0 32px}

/* ---- promo strip (text-only rotating "new arrivals") --------------------
   SHAREABLE SNIPPET: this CSS block + the promoStrip() markup/JS in render.js
   are self-contained (depend only on the --accent / --sans theme vars) so they
   lift cleanly into the standalone-tier sister sites. Text-only, no images.
   White ink on the per-site --accent (same contrast contract as .nsm-cta). */
.promo-strip{position:relative;z-index:96;width:100%;min-height:40px;
  background:var(--accent);color:#fff;font-family:var(--sans);
  display:flex;align-items:center;justify-content:center;
  padding:9px 46px 9px 16px;text-align:center}
.promo-strip .promo-track{max-width:100%;overflow:hidden}
.promo-strip a.promo-item{color:#fff;display:none;align-items:center;gap:8px;
  max-width:92vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  font-size:13px;letter-spacing:.03em}
.promo-strip a.promo-item.on{display:inline-flex}
.promo-strip a.promo-item:hover{text-decoration:underline}
.promo-strip .promo-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.promo-strip .promo-dot{opacity:.6;flex:none}
.promo-strip .promo-tag{font-weight:600;letter-spacing:.16em;text-transform:uppercase;
  font-size:11px;opacity:.92;flex:none}
.promo-strip .promo-arrow{opacity:.85;flex:none}
.promo-x{position:absolute;right:12px;top:50%;transform:translateY(-50%);
  background:transparent;border:0;color:#fff;opacity:.7;cursor:pointer;
  font-size:18px;line-height:1;padding:4px 6px}
.promo-x:hover{opacity:1}
/* when the in-flow promo strip is present, drop the FIXED chrome below it so
   the topbar/menu/toggle don't overlap the strip (body.promo-on set server-side
   to avoid a layout flash; the dismiss JS removes it). */
body.promo-on .topbar{top:40px}
body.promo-on .gucci-menu,body.promo-on .theme-toggle{top:62px}
@media(max-width:640px){
  .promo-strip{padding:8px 40px 8px 12px}
  .promo-strip a.promo-item{font-size:12px}
  body.promo-on .topbar{top:38px}
  body.promo-on .gucci-menu,body.promo-on .theme-toggle{top:58px}
}

/* top bar */
.topbar{position:fixed;top:0;left:0;right:0;z-index:90;display:flex;justify-content:space-between;
  align-items:center;padding:20px 36px;mix-blend-mode:normal}
.brand{font-family:var(--serif);font-size:21px;letter-spacing:.04em;color:var(--top-fg,#fff)}
.gucci-menu{position:fixed;top:22px;right:36px;z-index:100;display:inline-flex;align-items:center;
  gap:12px;background:transparent;border:0;padding:6px 4px;cursor:pointer;color:var(--top-fg,#fff);
  opacity:.78;transition:opacity .25s}
.gucci-menu:hover{opacity:1}
.gucci-menu__bars{position:relative;width:18px;height:12px}
.gucci-menu__bars span{position:absolute;left:0;right:0;height:1px;background:currentColor;
  transition:transform .3s,opacity .3s}
.gucci-menu__bars span:nth-child(1){top:0}
.gucci-menu__bars span:nth-child(2){top:5.5px}
.gucci-menu__bars span:nth-child(3){top:11px}
.gucci-menu[aria-expanded=true] .gucci-menu__bars span:nth-child(1){top:5.5px;transform:rotate(45deg)}
.gucci-menu[aria-expanded=true] .gucci-menu__bars span:nth-child(2){opacity:0}
.gucci-menu[aria-expanded=true] .gucci-menu__bars span:nth-child(3){top:5.5px;transform:rotate(-45deg)}
.gucci-menu__label{font-size:11px;font-weight:400;letter-spacing:.18em;text-transform:uppercase;line-height:1}
.navpanel{position:fixed;top:0;right:-320px;width:300px;height:100%;background:rgba(15,15,15,.97);
  backdrop-filter:blur(14px);z-index:99;padding:96px 36px;transition:right .35s;color:#f3f0ea}
.navpanel.open{right:0}
.navpanel a{display:block;padding:13px 0;font-size:14px;letter-spacing:.06em;text-transform:uppercase;
  border-bottom:1px solid rgba(255,255,255,.1)}
.theme-toggle{position:fixed;top:22px;right:124px;z-index:101;background:transparent;border:0;
  cursor:pointer;color:var(--top-fg,#fff);padding:4px;display:inline-flex;align-items:center;
  opacity:.78;transition:opacity .25s}
.theme-toggle:hover{opacity:1}
.theme-toggle .icon-moon{display:none}
html[data-theme=dark] .theme-toggle .icon-sun{display:none}
html[data-theme=dark] .theme-toggle .icon-moon{display:inline-block}

/* hero */
.cinema{position:relative;width:100%;min-height:clamp(560px,90vh,1080px);overflow:hidden;background:#111}
.cinema-slide{position:absolute;inset:0;background-size:cover;background-position:center 14%;
  opacity:0;transition:opacity 1.4s ease}
.cinema-slide.on{opacity:1}
.cinema-grid{position:absolute;inset:0;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr}
.cinema-grid div{background-size:cover;background-position:center 14%}
.cinema::after{content:"";position:absolute;inset:0;background:linear-gradient(
  180deg,rgba(0,0,0,.34) 0%,rgba(0,0,0,.05) 38%,rgba(0,0,0,.5) 100%)}
.hero-cta{position:absolute;bottom:7%;left:50%;transform:translateX(-50%);display:flex;
  flex-direction:column;align-items:center;gap:15px;z-index:10}
.hero-cta__sub{font-size:13px;color:#fff;letter-spacing:.04em}
.hero-cta__btn{display:inline-block;background:#fff;color:#111;padding:14px 56px;border-radius:4px;
  font-size:12px;font-weight:500;letter-spacing:.16em;text-transform:uppercase;
  transition:background .25s,color .25s}
.hero-cta__btn:hover{background:#111;color:#fff}
.hero-mark{text-align:center;padding:46px 20px 14px}
.hero-mark h1{font-family:var(--serif);font-size:clamp(40px,6vw,76px);font-weight:500;
  letter-spacing:.02em;line-height:1.05}
.hero-mark p{color:var(--muted);font-size:15px;margin-top:10px;letter-spacing:.03em}

/* controls */
.controls{display:flex;gap:18px;align-items:center;flex-wrap:wrap;padding:26px 0 20px;
  justify-content:space-between;border-bottom:1px solid var(--rule)}
.ctl-sort{display:flex;align-items:center;gap:10px;flex:1;min-width:260px;flex-wrap:wrap}
.ctl-density{display:flex;align-items:center;gap:10px;flex:1;min-width:260px;justify-content:flex-end;flex-wrap:wrap}
.controls label{font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
.controls select,.controls input[type=search]{font-family:var(--sans);font-size:14px;padding:9px 12px;
  border:1px solid var(--rule);background:var(--card);color:var(--fg);border-radius:3px}
.controls input[type=range]{flex:1;min-width:140px;max-width:200px;accent-color:var(--accent)}
.controls input[type=search]{min-width:160px;flex:1}
.count{font-size:13px;color:var(--muted)}

/* grid */
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:26px;padding:36px 0 60px}
.card{background:var(--card);border:1px solid var(--rule);border-radius:4px;overflow:hidden;
  cursor:pointer;transition:transform .2s,box-shadow .2s;display:flex;flex-direction:column}
.card:hover{transform:translateY(-3px);box-shadow:0 12px 30px rgba(0,0,0,.13)}
.card-link{display:flex;flex-direction:column;height:100%;color:inherit}
.card-img{aspect-ratio:1/1;background:#eee center/cover no-repeat;position:relative;overflow:hidden}
.card-img::after{content:"";position:absolute;inset:0;background-image:var(--card-bg,none);background-repeat:repeat;background-size:33.33% 33.33%;background-position:center;opacity:0;transition:opacity 360ms ease;pointer-events:none}
.card:hover .card-img::after,.card-link:focus-visible .card-img::after{opacity:1}
@media (prefers-reduced-motion: reduce){.card-img::after{transition:none}}
.card-body{padding:13px 14px 16px}
.card-title{font-family:var(--serif);font-size:16px;line-height:1.25;min-height:40px}
.card-meta{font-size:11px;color:var(--muted);letter-spacing:.04em;margin-top:6px;
  display:flex;align-items:center;gap:7px}
.dot{width:11px;height:11px;border-radius:50%;border:1px solid rgba(0,0,0,.18);flex:none}
.card-sample{margin-top:10px;font-size:11px;letter-spacing:.1em;text-transform:uppercase;
  color:var(--accent)}

/* footer */
footer{border-top:1px solid var(--rule);padding:48px 0 60px;margin-top:30px}
footer .wrap{display:flex;justify-content:space-between;flex-wrap:wrap;gap:24px;
  font-size:13px;color:var(--muted)}
footer .fb{font-family:var(--serif);font-size:18px;color:var(--fg)}

/* ns-modal (chip / detail) */
.nsm-back{position:fixed;inset:0;background:rgba(10,9,7,.66);backdrop-filter:blur(3px);
  z-index:200;display:none;align-items:center;justify-content:center;padding:24px}
.nsm-back.open{display:flex}
.nsm{background:var(--card);color:var(--fg);max-width:760px;width:100%;border-radius:6px;
  overflow:hidden;display:grid;grid-template-columns:1fr 1fr;max-height:88vh}
.nsm-img{background:#eee center/cover no-repeat;min-height:340px}
.nsm-body{padding:34px;overflow:auto}
.nsm-body h3{font-family:var(--serif);font-size:26px;margin-bottom:6px}
.nsm-body .sku{font-size:12px;color:var(--muted);letter-spacing:.06em;margin-bottom:18px}
.nsm-chips{display:flex;flex-wrap:wrap;gap:6px;margin:14px 0}
.nsm-chips span{font-size:11px;padding:4px 9px;border:1px solid var(--rule);border-radius:20px;
  color:var(--muted)}
.nsm-cta{display:inline-block;margin-top:18px;background:var(--accent);color:#fff;padding:13px 26px;
  border-radius:4px;font-size:12px;letter-spacing:.12em;text-transform:uppercase}
.nsm-close{position:absolute;top:18px;right:22px;font-size:26px;cursor:pointer;color:#fff;z-index:201}
@media(max-width:640px){.nsm{grid-template-columns:1fr}.nsm-img{min-height:220px}}
@media(max-width:900px){.wrap{padding:0 18px}}
`;
}

/* ---------- top bar + nav + theme toggle ---------- */
function chrome(cfg) {
  return `
<div class="topbar">
  <a class="brand" href="/">${esc(cfg.siteName)}</a>
  <div style="display:flex;gap:14px;align-items:center">
    <button class="theme-toggle" id="themeBtn" aria-label="Toggle theme">
      <svg class="icon-sun" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
        <circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/></svg>
      <svg class="icon-moon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
        <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
    </button>
  </div>
</div>
<button class="gucci-menu" id="menuBtn" aria-expanded="false" aria-controls="navpanel">
  <span class="gucci-menu__bars" aria-hidden="true"><span></span><span></span><span></span></span>
  <span class="gucci-menu__label">Menu</span>
</button>
<nav class="navpanel" id="navpanel">
  <a href="/catalog">Designs</a>
  <a href="/about">About</a>
  <a href="/info">Info</a>
  <a href="mailto:${esc(cfg.siteEmail)}">Contact</a>
</nav>`;
}

/* ---------- promo strip (rotating "new arrivals") ----------
 * SHAREABLE SNIPPET (pairs with the .promo-strip CSS in css()). Text-only,
 * no images. Shows "NEW · <title> →" rotating through the 3 NEWEST products.
 *
 * Newest signal: `products` is already sorted newest-first by the server
 * (sortProducts(POOL,'newest') = created_at desc — the schema has no
 * published_at), so the first 3 ARE the 3 newest. Link target is the fleet's
 * OWN vendor-safe /product/<slug> route (productSlug strips vendor tokens) —
 * never an external vendor URL (settlement vendor-confidentiality, the same
 * rule the cards / buy route already honour).
 *
 * Per-site flag: cfg.promoBanner. Defaults ON; only an explicit `false`
 * disables it. Returns '' (no strip, no body.promo-on offset) when disabled or
 * when there are no products to show. Self-contained: depends only on esc /
 * clean / scrubVendor / productSlug already in this module.
 */
function promoStrip(cfg, products) {
  if (cfg.promoBanner === false) return '';
  const items = (Array.isArray(products) ? products : []).slice(0, 3).map(p => {
    const t = scrubVendor(clean(p.title).split('|')[0].trim())
            || scrubVendor((p.sku || p.handle || '').replace(/-Sample$/i, ''))
            || 'New Design';
    return { t, href: '/product/' + encodeURIComponent(productSlug(p)) };
  }).filter(x => x.t);
  if (!items.length) return '';
  const links = items.map((it, i) =>
    `<a class="promo-item${i === 0 ? ' on' : ''}" href="${esc(it.href)}">` +
    `<span class="promo-tag">New</span><span class="promo-dot" aria-hidden="true">&middot;</span>` +
    `<span class="promo-name">${esc(it.t)}</span>` +
    `<span class="promo-arrow" aria-hidden="true">&rarr;</span></a>`
  ).join('');
  return `
<div class="promo-strip" id="promoStrip" role="region" aria-label="New arrivals">
  <div class="promo-track">${links}</div>
  <button class="promo-x" id="promoX" type="button" aria-label="Dismiss new-arrivals banner">&times;</button>
</div>
<script>
(function(){
  var k='${cfg.slug}_promo_dismiss';
  var strip=document.getElementById('promoStrip');
  if(!strip)return;
  try{ if(localStorage.getItem(k)==='1'){ strip.style.display='none';
    document.body.classList.remove('promo-on'); return; } }catch(e){}
  var items=strip.querySelectorAll('.promo-item'),i=0;
  if(items.length>1){ setInterval(function(){
    if(document.hidden)return;   // don't advance while the tab is backgrounded
    items[i].classList.remove('on'); i=(i+1)%items.length; items[i].classList.add('on');
  },4200); }
  var x=document.getElementById('promoX');
  x&&x.addEventListener('click',function(){
    strip.style.display='none'; document.body.classList.remove('promo-on');
    try{ localStorage.setItem(k,'1'); }catch(e){}
  });
})();
</script>`;
}

function script(cfg) {
  return `
<script>
(function(){
  var k='${cfg.slug}_theme';
  var saved=localStorage.getItem(k);
  if(saved)document.documentElement.dataset.theme=saved;
  var btn=document.getElementById('themeBtn');
  btn&&btn.addEventListener('click',function(){
    var cur=document.documentElement.dataset.theme==='dark'?'':'dark';
    if(cur)document.documentElement.dataset.theme=cur;else delete document.documentElement.dataset.theme;
    localStorage.setItem(k,cur);
  });
  var mb=document.getElementById('menuBtn'),np=document.getElementById('navpanel');
  mb&&mb.addEventListener('click',function(){
    var o=np.classList.toggle('open');
    mb.setAttribute('aria-expanded',o?'true':'false');
  });
})();
</script>`;
}

/* anti-flash theme + top-bar fg colour
 * canonicalOverride — when set, rel=canonical points there instead of this
 * site's own URL. Product pages pass the SKU's canonical home on the main
 * store so shared SKUs never compete as duplicate content (panel SEO must-do).
 */
function head(cfg, title, desc, path, canonicalOverride, bodyClass) {
  const url = 'https://' + cfg.domain + (path || '');
  const canonical = canonicalOverride || url;
  return `<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<meta name="description" content="${esc(desc)}">
<meta name="brand" content="${esc(cfg.siteName)}">
<link rel="canonical" href="${esc(canonical)}">
<meta property="og:type" content="website"><meta property="og:site_name" content="${esc(cfg.siteName)}">
<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}">
<meta property="og:url" content="${esc(url)}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${esc(title)}"><meta name="twitter:description" content="${esc(desc)}">
<style>:root{--top-fg:#fff}</style>
<script>(function(){var s=localStorage.getItem('${cfg.slug}_theme');if(s)document.documentElement.dataset.theme=s;})();</script>
<style>${css(cfg)}</style>
</head><body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>`;
}

function jsonld(cfg) {
  const org = {
    '@context':'https://schema.org','@type':'Organization',
    name: cfg.siteName, url:'https://'+cfg.domain, email: cfg.siteEmail,
    parentOrganization:{ '@type':'Organization', name:'Designer Wallcoverings',
      address:{ '@type':'PostalAddress', streetAddress:'15442 Ventura Bl #102',
        addressLocality:'Sherman Oaks', addressRegion:'CA', postalCode:'91403' } }
  };
  return `<script type="application/ld+json">${JSON.stringify(org).replace(/<\/script/gi,'<\\/script')}</script>`;
}

/* ---------- card ----------
 * Wrapper is a <div> (NOT <a>) — an <a> wrapping the inner controls would be
 * split by the browser's Adoption Agency Algorithm into sibling grid items.
 * The crawlable link is an inner <a class=card-link> pointing at
 * /product/:slug (productSlug — "wallpaper"-free) so search engines reach
 * every per-SKU page on a clean URL. data-handle carries the CLEAN productSlug
 * (vendor-free) — the quick-view buy-sample link points at the fleet's own
 * /buy/:slug route, which resolves the real Shopify handle SERVER-SIDE and
 * 302-redirects to the DW store, so the raw vendor-laden handle never reaches
 * the rendered DOM (2026-05-29 settlement vendor-confidentiality, DTD verdict B).
 */
function card(p) {
  const hx = dominantHex(p);
  const t = scrubVendor(clean(p.title).split('|')[0].trim())
            || scrubVendor((p.sku||p.handle||'').replace(/-Sample$/i,''))
            || 'Designer Wallcovering';
  const href = '/product/' + encodeURIComponent(productSlug(p));
  const img = proxyImg(p.image_url);   // vendor-neutral /img/<token> (see imgToken)
  return `<div class="card" data-handle="${esc(productSlug(p))}"
    data-title="${esc(t)}" data-sku="${esc(scrubVendor(p.sku||p.handle))}"
    data-img="${esc(img)}" data-tags="${esc(scrubTags(p.tags).slice(0,12).join(', '))}"
    data-price="${esc(p.price)}" data-hex="${esc(hx)}">
  <a class="card-link" href="${esc(href)}" aria-label="${esc(t)}">
    <div class="card-img" style="background-image:url('${esc(img)}');--card-bg:url('${esc(img)}')"></div>
    <div class="card-body">
      <div class="card-title">${esc(t)}</div>
      <div class="card-meta"><span class="dot" style="background:${hx}"></span>
        Designer Wallcoverings · ${esc(scrubVendor(p.sku||p.handle))}</div>
      <div class="card-sample">View Details</div>
    </div>
  </a></div>`;
}

/* ---------- home page ---------- */
function homePage(cfg, heroImgs, featured) {
  const title = `${cfg.siteName} | ${cfg.tagline}`;
  const desc = clean(cfg.metaDesc);
  // Hero images are raw Shopify CDN urls (vendor in filename) — route them
  // through the same /img proxy so the homepage hero doesn't leak the vendor.
  const slides = heroImgs.slice(0, 8).map(proxyImg).filter(Boolean);
  const heroHtml = slides.length >= 3
    ? slides.map((s,i)=>`<div class="cinema-slide${i===0?' on':''}" style="background-image:url('${esc(s)}')"></div>`).join('')
    : `<div class="cinema-grid">${slides.concat(slides).slice(0,4).map(s=>`<div style="background-image:url('${esc(s)}')"></div>`).join('')}</div>`;
  // Rotating "new arrivals" strip — first element in <body>, above topbar+hero.
  // featured is already newest-first (created_at desc); promoStrip takes the 3
  // newest. body.promo-on (set server-side) offsets the fixed chrome below it.
  const promo = promoStrip(cfg, featured);
  return head(cfg, title, desc, '/', null, promo ? 'promo-on' : '') + promo + jsonld(cfg) + chrome(cfg) + `
<header class="cinema" data-hero-mode="huge">
  ${heroHtml}
  <div class="hero-cta">
    <span class="hero-cta__sub">${esc(clean(cfg.heroSub))}</span>
    <a class="hero-cta__btn" href="/catalog">Enter</a>
  </div>
</header>
<div class="hero-mark">
  <h1>${esc(cfg.heroHeadline)}</h1>
  <p>${esc(clean(cfg.tagline))} · Curated by Designer Wallcoverings</p>
</div>
<div class="wrap">
  <div class="controls">
    <div class="ctl-sort">
      <label for="sortSel">Featured Selection · Sort</label>
      <select id="sortSel">
        <option value="newest">Featured</option><option value="color">Color</option>
        <option value="light-dark">Light → Dark</option><option value="dark-light">Dark → Light</option>
        <option value="style">Style</option><option value="sku">SKU A→Z</option>
        <option value="title">Title A→Z</option><option value="price-asc">Price ↑</option>
        <option value="price-desc">Price ↓</option>
      </select>
    </div>
    <div class="ctl-density">
      <label for="densSel">Density</label>
      <input type="range" id="densSel" min="3" max="8" value="5">
      <span class="count">${featured.length} patterns</span>
    </div>
  </div>
  <div class="grid" id="grid">${featured.map(card).join('')}</div>
  <div style="text-align:center;padding:10px 0 50px">
    <a class="hero-cta__btn" style="position:static;background:var(--accent);color:#fff" href="/catalog">View Full Catalog</a>
  </div>
</div>
${footer(cfg)}
${nsModal(cfg)}
${script(cfg)}
${gridControls(cfg)}
${heroRotate(slides.length)}
</body></html>`;
}

/* client-side sort + density for the homepage Featured grid (catalog does it
   server-side; the featured set is small + server-rendered, so reorder in JS).
   Shares localStorage keys with /catalog so the shopper's choice carries over. */
function gridControls(cfg){
  return `<script>
(function(){
  var dk='${cfg.slug}_density',sk='${cfg.slug}_sort';
  var g=document.getElementById('grid'),d=document.getElementById('densSel'),s=document.getElementById('sortSel');
  if(!g||!d||!s)return;
  var cards=Array.prototype.slice.call(g.children);
  cards.forEach(function(c,i){c.__i=i;}); // original "Featured" order
  var sv=localStorage.getItem(dk);if(sv)d.value=sv;
  function applyD(){g.style.setProperty('--cols',d.value);}
  d.addEventListener('input',function(){applyD();localStorage.setItem(dk,d.value);});
  applyD();
  function hl(hex){var m=/([0-9a-f]{6})/i.exec(hex||'');if(!m)return[999,999];
    var n=parseInt(m[1],16),r=(n>>16&255)/255,gg=(n>>8&255)/255,b=(n&255)/255;
    var mx=Math.max(r,gg,b),mn=Math.min(r,gg,b),l=(mx+mn)/2,h=0,df=mx-mn;
    if(df){if(mx==r)h=((gg-b)/df)%6;else if(mx==gg)h=(b-r)/df+2;else h=(r-gg)/df+4;h*=60;if(h<0)h+=360;}
    return[h,l];}
  function val(c,k){var x=c.dataset;
    if(k==='title')return(x.title||'').toLowerCase();
    if(k==='sku')return(x.sku||'').toLowerCase();
    if(k==='style')return(x.tags||'').toLowerCase();
    if(k==='price-asc'||k==='price-desc')return parseFloat(x.price)||0;
    return hl(x.hex);}
  function sortBy(k){
    var arr=cards.slice();
    arr.sort(function(a,b){
      if(k==='newest')return a.__i-b.__i;
      if(k==='price-asc')return val(a,k)-val(b,k);
      if(k==='price-desc')return val(b,k)-val(a,k);
      if(k==='color')return val(a,'color')[0]-val(b,'color')[0];
      if(k==='light-dark')return val(b,'color')[1]-val(a,'color')[1];
      if(k==='dark-light')return val(a,'color')[1]-val(b,'color')[1];
      var av=val(a,k),bv=val(b,k);return av<bv?-1:av>bv?1:0;
    });
    arr.forEach(function(c){g.appendChild(c);});
  }
  var saved=localStorage.getItem(sk);
  if(saved){var ok=Array.prototype.some.call(s.options,function(o){return o.value===saved;});if(ok){s.value=saved;sortBy(saved);}}
  s.addEventListener('change',function(){localStorage.setItem(sk,s.value);sortBy(s.value);});
})();
</script>`;
}

/* ---------- catalog page ---------- */
function catalogPage(cfg, products, q, sort, page, pages, total) {
  const title = `Catalog — ${cfg.siteName}`;
  const desc = `Browse ${total} ${clean(cfg.nicheLabel)} from Designer Wallcoverings. Free memo samples, trade service, expert hand-holding on every order.`;
  const sopt = (v,l)=>`<option value="${v}"${sort===v?' selected':''}>${l}</option>`;
  return head(cfg, title, desc, '/catalog') + jsonld(cfg) + chrome(cfg) + `
<div style="height:84px"></div>
<div class="wrap">
  <div class="hero-mark" style="padding:30px 0 4px">
    <h1 style="font-size:clamp(32px,4vw,52px)">${esc(cfg.siteName)}</h1>
    <p>${esc(clean(cfg.tagline))}</p>
  </div>
  <form class="controls" method="get" action="/catalog" id="ctlForm">
    <div class="ctl-sort">
      <input type="search" name="q" value="${esc(q)}" placeholder="Search ${total} patterns…">
      <label for="sortSel">Sort</label>
      <select name="sort" id="sortSel" onchange="document.getElementById('ctlForm').submit()">
        ${sopt('newest','Newest')}${sopt('color','Color')}${sopt('light-dark','Light → Dark')}
        ${sopt('dark-light','Dark → Light')}${sopt('style','Style')}${sopt('sku','SKU A→Z')}
        ${sopt('title','Title A→Z')}${sopt('price-asc','Price ↑')}${sopt('price-desc','Price ↓')}
      </select>
    </div>
    <div class="ctl-density">
      <label for="densSel">Density</label>
      <input type="range" id="densSel" min="3" max="8" value="5">
      <span class="count">${total} patterns${q?` · "${esc(q)}"`:''}</span>
    </div>
  </form>
  <div class="grid" id="grid">${products.map(card).join('')}</div>
  ${pages>1?pager(q,sort,page,pages):''}
</div>
${footer(cfg)}
${nsModal(cfg)}
${script(cfg)}
<script>
(function(){
  var dk='${cfg.slug}_density',sk='${cfg.slug}_sort';
  var d=document.getElementById('densSel'),g=document.getElementById('grid');
  // hydrate density from localStorage BEFORE first paint (no flash from 5 → saved)
  var sv=localStorage.getItem(dk);if(sv)d.value=sv;
  function apply(){g.style.setProperty('--cols',d.value);}
  d.addEventListener('input',function(){apply();localStorage.setItem(dk,d.value);});
  apply();
  // hydrate sort dropdown from localStorage when URL has no ?sort= override
  // (so the dropdown shows the user's last choice on a fresh /catalog visit)
  var ss=document.getElementById('sortSel');
  var ssSaved=localStorage.getItem(sk);
  if(ssSaved && !/[?&]sort=/.test(location.search)){
    var ok=Array.prototype.some.call(ss.options,function(o){return o.value===ssSaved;});
    if(ok)ss.value=ssSaved;
  }
  ss.addEventListener('change',function(){localStorage.setItem(sk,ss.value);});
})();
</script>
</body></html>`;
}

function pager(q,sort,page,pages){
  const link=(p)=>`/catalog?q=${encodeURIComponent(q)}&sort=${sort}&page=${p}`;
  let html='<div style="display:flex;gap:8px;justify-content:center;padding:10px 0 50px;flex-wrap:wrap">';
  const lo=Math.max(1,page-3),hi=Math.min(pages,page+3);
  if(page>1)html+=`<a class="hero-cta__btn" style="position:static;padding:9px 16px" href="${link(page-1)}">Prev</a>`;
  for(let i=lo;i<=hi;i++)html+=`<a href="${link(i)}" style="padding:9px 14px;border:1px solid var(--rule);border-radius:3px;${i===page?'background:var(--accent);color:#fff':''}">${i}</a>`;
  if(page<pages)html+=`<a class="hero-cta__btn" style="position:static;padding:9px 16px" href="${link(page+1)}">Next</a>`;
  return html+'</div>';
}

function aboutPage(cfg){
  const title=`About — ${cfg.siteName}`;
  return head(cfg,title,clean(cfg.metaDesc),'/about')+jsonld(cfg)+chrome(cfg)+`
<div style="height:84px"></div>
<div class="wrap" style="max-width:760px">
  <div class="hero-mark" style="padding:40px 0 10px"><h1>${esc(cfg.siteName)}</h1></div>
  <p style="font-size:17px;line-height:1.8;color:var(--fg);margin:20px 0">${esc(clean(cfg.aboutCopy))}</p>
  <p style="font-size:15px;line-height:1.8;color:var(--muted);margin:18px 0">
    ${esc(cfg.siteName)} is part of Designer Wallcoverings — a Sherman Oaks design house serving
    the trade since 1986. Every pattern ships with a free memo sample, and our team hand-holds
    each order from selection through installation. Reach us at
    <a href="mailto:${esc(cfg.siteEmail)}" style="color:var(--accent)">${esc(cfg.siteEmail)}</a>.</p>
  <p style="margin:26px 0 60px"><a class="hero-cta__btn" style="position:static;background:var(--accent);color:#fff" href="/catalog">Browse the Catalog</a></p>
</div>
${footer(cfg)}${script(cfg)}</body></html>`;
}

/* ---------- info page ----------
 * Per-site informational page: memo samples, trade program, shipping &
 * lead times, and a short FAQ. Driven entirely by cfg so it reads well for
 * any fleet site. rel=canonical points to this site's own /info.
 */
function infoPage(cfg){
  const title=`Info — ${cfg.siteName}`;
  const niche=clean(cfg.nicheLabel);
  const desc=`Memo samples, trade program, shipping and lead times for ${clean(cfg.siteName)} — ${niche} from a Sherman Oaks design house.`;
  const sec=(h,body)=>`
  <section style="margin:34px 0;padding-bottom:30px;border-bottom:1px solid var(--rule)">
    <h2 style="font-family:var(--serif);font-size:clamp(24px,3vw,34px);margin-bottom:12px">${h}</h2>
    ${body}
  </section>`;
  const faq=[
    ['Do memo samples cost anything?',
     `No. Memo samples are free and mailed to you so you can judge colour, texture, and scale in your own light before committing.`],
    ['How many samples can I request?',
     `Request as many as your project needs. We would rather you see every ${niche} option in person than guess from a screen.`],
    ['Can you help me choose?',
     `Yes. Our team hand-holds every order — share your room, lighting, and palette and we will narrow the field with you.`],
    ['Do you work with designers and architects?',
     `Absolutely — the trade program below is built for designers, architects, and specifiers.`]
  ];
  return head(cfg,title,desc,'/info')+jsonld(cfg)+chrome(cfg)+`
<div style="height:84px"></div>
<div class="wrap" style="max-width:820px">
  <div class="hero-mark" style="padding:40px 0 6px"><h1>Information</h1>
    <p>${esc(clean(cfg.siteName))} · ${esc(niche)}</p></div>

  ${sec('Memo Samples', `
    <p style="font-size:16px;line-height:1.8;color:var(--fg)">
      Every pattern in our catalogue ships with a <strong>free memo sample</strong> — a mailed
      swatch you can hold against your wall, in your own light, before you order a single roll.
      Browse the catalogue, request the swatches that interest you, and we will put them in the
      post. There is no charge and no obligation.</p>
    <p style="margin-top:16px"><a class="hero-cta__btn" style="position:static;background:var(--accent);color:#fff" href="/catalog">Browse &amp; Request Samples</a></p>`)}

  ${sec('Trade Program', `
    <p style="font-size:16px;line-height:1.8;color:var(--fg)">
      Designers, architects, and specifiers receive trade pricing, priority memo-sample
      handling, and a dedicated point of contact for every project. If you work in the
      trade, enrol once and the pricing applies across the full ${esc(niche)} collection.</p>
    <p style="margin-top:14px"><a href="${DW}/pages/trade" rel="noopener noreferrer"
      style="color:var(--accent);font-weight:500">View the Trade Program &rarr;</a></p>`)}

  ${sec('Shipping &amp; Lead Times', `
    <p style="font-size:16px;line-height:1.8;color:var(--fg)">
      Memo samples typically ship within one to two business days. Full orders are made to
      order — most ${esc(niche)} ship in roughly two to four weeks, though hand-finished and
      specialty lines can take longer. We confirm the lead time for your specific selection
      before the order is placed, and our team keeps you posted from selection through
      delivery and installation. We ship across the United States.</p>`)}

  ${sec('Frequently Asked', faq.map(([q,a])=>`
    <div style="margin:16px 0">
      <div style="font-family:var(--serif);font-size:19px;color:var(--fg)">${q}</div>
      <p style="font-size:15px;line-height:1.75;color:var(--muted);margin-top:5px">${a}</p>
    </div>`).join(''))}

  <p style="margin:6px 0 60px;font-size:15px;color:var(--muted);line-height:1.8">
    Still have a question? Email
    <a href="mailto:${esc(cfg.siteEmail)}" style="color:var(--accent)">${esc(cfg.siteEmail)}</a>
    and a real person will answer.</p>
</div>
${footer(cfg)}${script(cfg)}</body></html>`;
}

function footer(cfg){
  return `<footer><div class="wrap">
  <div><div class="fb">${esc(cfg.siteName)}</div>
    <div style="margin-top:6px">${esc(clean(cfg.tagline))}</div>
    <div style="margin-top:4px"><a href="mailto:${esc(cfg.siteEmail)}">${esc(cfg.siteEmail)}</a></div></div>
  <div><strong style="color:var(--fg)">Designer Wallcoverings</strong><br>
    15442 Ventura Bl #102, Sherman Oaks CA 91403<br>
    <a href="${DW}">designerwallcoverings.com</a></div>
  <div>Free memo samples · Trade service<br>Expert order hand-holding<br>
    &copy; ${new Date().getFullYear()} ${esc(cfg.siteName)}</div>
</div></footer>`;
}

function nsModal(cfg){
  return `<div class="nsm-back" id="nsmBack">
  <div class="nsm" onclick="event.stopPropagation()">
    <div class="nsm-close" id="nsmClose">&times;</div>
    <div class="nsm-img" id="nsmImg"></div>
    <div class="nsm-body">
      <h3 id="nsmTitle"></h3><div class="sku" id="nsmSku"></div>
      <div class="nsm-chips" id="nsmChips"></div>
      <p style="font-size:14px;color:var(--muted);line-height:1.7">
        Available through Designer Wallcoverings with a free memo sample.
        Our team will hand-hold your order — from selection through installation.</p>
      <a class="nsm-cta" id="nsmCta" target="_blank" rel="noopener noreferrer">Order Memo Sample</a>
    </div>
  </div>
</div>
<script>
(function(){
  var back=document.getElementById('nsmBack');
  function open(c){
    document.getElementById('nsmImg').style.backgroundImage="url('"+c.dataset.img+"')";
    document.getElementById('nsmTitle').textContent=c.dataset.title;
    document.getElementById('nsmSku').textContent='Designer Wallcoverings · '+c.dataset.sku;
    var chips=document.getElementById('nsmChips');chips.innerHTML='';
    (c.dataset.tags||'').split(',').map(function(s){return s.trim();}).filter(Boolean).slice(0,8)
      .forEach(function(t){var s=document.createElement('span');s.textContent=t;chips.appendChild(s);});
    document.getElementById('nsmCta').href='/buy/'+encodeURIComponent(c.dataset.handle);
    back.classList.add('open');
  }
  // cards are real crawlable links to /product/:handle — they navigate.
  // the modal stays available for any element opting in via .quick-view.
  document.addEventListener('click',function(e){
    var c=e.target.closest('.quick-view');if(c){e.preventDefault();open(c);return;}
  });
  document.getElementById('nsmClose').addEventListener('click',function(){back.classList.remove('open');});
  back.addEventListener('click',function(){back.classList.remove('open');});
})();
</script>`;
}

function heroRotate(n){
  if(n<3)return '';
  return `<script>
(function(){
  var sl=document.querySelectorAll('.cinema-slide'),i=0;
  if(sl.length<2)return;
  setInterval(function(){
    sl[i].classList.remove('on');
    i=(i+1)%sl.length;
    sl[i].classList.add('on');
  },5200);
})();
</script>`;
}

/* ---------- product page ----------
 * Per-SKU page. rel=canonical points to the SKU's canonical home on the main
 * store (designerwallcoverings.com/products/<handle>) so a shared SKU rendered
 * across multiple fleet sites never competes with the main store or its
 * siblings as duplicate content. Title + meta description are per-site-unique
 * (carry the site name + tagline), satisfying the panel SEO must-do.
 */
function productPage(cfg, p, related) {
  const name = scrubVendor(clean(p.title).split('|')[0].trim())
            || scrubVendor((p.sku||p.handle||'').replace(/-Sample$/i,''))
            || 'Designer Wallcovering';
  const sku = scrubVendor(p.sku || p.handle);   // display-only; raw p.handle still used for DW-store links
  const imgProxy = proxyImg(p.image_url);                                   // /img/<token> — vendor-neutral
  const imgAbs = imgProxy ? `https://${cfg.domain}${imgProxy}` : '';        // absolute for JSON-LD image
  // Self-canonical on the CLEAN slug (vendor-free). Was ${DW}/products/<rawHandle>,
  // which leaked 3rd-party vendor names in the rendered <link>/JSON-LD. Buy + main-store
  // links now route through /buy/:slug (server resolves the real handle + 302-redirects),
  // so the vendor handle never appears in the DOM.
  const canonical = `https://${cfg.domain}/product/${encodeURIComponent(productSlug(p))}`;
  // per-site-unique title + meta — site name makes each fleet copy distinct
  const title = `${name} — ${sku} | ${cfg.siteName}`;
  const desc = `${name} (${sku}) — ${clean(cfg.tagline)} Available through ${cfg.siteName} and Designer Wallcoverings with a free memo sample and expert order hand-holding.`;
  const hx = dominantHex(p);
  const chips = scrubTags(p.tags).filter(Boolean).slice(0, 12)
    .map(t => `<span>${esc(t)}</span>`).join('');
  const productLd = {
    '@context':'https://schema.org','@type':'Product',
    name, sku, image: imgAbs, brand:{ '@type':'Brand', name:'Designer Wallcoverings' },
    url: canonical
  };
  return head(cfg, title, desc, '/product/' + encodeURIComponent(productSlug(p)), canonical)
    + `<script type="application/ld+json">${JSON.stringify(productLd).replace(/<\/script/gi,'<\\/script')}</script>`
    + jsonld(cfg) + chrome(cfg) + `
<!-- Theme toggle (theme1 / theme2) — query-param-gated preview surface
     (Steve 2026-05-29). dw-domain-fleet has no admin auth, so this is
     security-by-obscurity: ?theme=2 in the URL flips to theme2 and persists
     in localStorage. The toggle UI itself is hidden until theme2 mode is
     active so default visitors never see it. Shares localStorage key
     'wallco-page-theme' for cross-site continuity. -->
<script>
(function(){
  var qs = new URLSearchParams(location.search);
  var fromUrl = qs.get('theme');
  var saved = null; try { saved = localStorage.getItem('wallco-page-theme'); } catch(e){}
  var theme = null;
  if (fromUrl === '2' || fromUrl === 'theme2') theme = 'theme2';
  else if (fromUrl === '1' || fromUrl === 'theme1') theme = 'theme1';
  else if (saved === 'theme2' || saved === 'theme1') theme = saved;
  if (theme) {
    document.documentElement.setAttribute('data-page-theme', theme);
    if (fromUrl) { try { localStorage.setItem('wallco-page-theme', theme); } catch(e){} }
  }
  /* No opt-in → no attribute set, toggle stays display:none (gate works). */
})();
</script>
<style id="page-theme-css-fleet">
  [data-page-theme="theme2"] body{
    --bg:#ffffff !important; --ink:#0a0a0a !important;
    --muted:#5a5a5a !important; --line:#e5e5e5 !important;
    --card-bg:#fafafa !important; --accent:#c14a2e !important;
    background:#fff !important; color:#0a0a0a !important;
  }
  [data-page-theme="theme2"] h1,
  [data-page-theme="theme2"] h2,
  [data-page-theme="theme2"] h3{ letter-spacing:-0.01em; font-weight:500; }
  [data-page-theme="theme2"] .card{
    background:#fafafa !important; border:1px solid #e5e5e5 !important; box-shadow:none !important;
  }
  #page-theme-toggle{ display:none; }
  [data-page-theme] #page-theme-toggle{ display:inline-flex; }
  #page-theme-toggle{
    position:fixed; top:96px; right:14px; z-index:90;
    gap:0; align-items:center;
    background:rgba(255,255,255,.92); border:1px solid #d8d2c5;
    border-radius:999px; padding:3px; backdrop-filter:blur(6px);
    box-shadow:0 2px 10px rgba(0,0,0,.08);
    font:600 11px ui-sans-serif,system-ui; letter-spacing:.06em; text-transform:uppercase;
  }
  #page-theme-toggle button{
    border:0; background:transparent; color:#5a5048; cursor:pointer;
    padding:6px 14px; border-radius:999px; font:inherit; transition:all .15s;
  }
  #page-theme-toggle button[aria-pressed="true"]{ background:#1a1714; color:#faf7f2; }
  #page-theme-toggle button:hover:not([aria-pressed="true"]){ color:#1a1714; }
</style>
<div id="page-theme-toggle" role="group" aria-label="Page theme">
  <button type="button" data-theme="theme1" aria-pressed="false">Theme 1</button>
  <button type="button" data-theme="theme2" aria-pressed="false">Theme 2</button>
</div>
<script>
(function(){
  var cur = document.documentElement.getAttribute('data-page-theme') || 'theme1';
  var btns = document.querySelectorAll('#page-theme-toggle button');
  function sync(t){ btns.forEach(function(b){ b.setAttribute('aria-pressed', b.dataset.theme === t ? 'true' : 'false'); }); }
  sync(cur);
  btns.forEach(function(b){
    b.addEventListener('click', function(){
      var t = b.dataset.theme;
      document.documentElement.setAttribute('data-page-theme', t);
      try { localStorage.setItem('wallco-page-theme', t); } catch(e){}
      sync(t);
    });
  });
})();
</script>
<div style="height:84px"></div>
<div class="wrap">
  <div style="display:grid;grid-template-columns:1.05fr .95fr;gap:48px;padding:28px 0 10px"
       class="pdp-grid">
    <div class="card-img" style="background-image:url('${esc(imgProxy)}');
       border-radius:6px;min-height:clamp(360px,52vw,640px)"></div>
    <div>
      <div style="font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)">
        ${esc(cfg.siteName)}</div>
      <h1 style="font-family:var(--serif);font-size:clamp(30px,3.4vw,46px);margin:10px 0 6px">
        ${esc(name)}</h1>
      <div class="card-meta" style="margin-bottom:18px"><span class="dot" style="background:${hx}"></span>
        Designer Wallcoverings · ${esc(sku)}</div>
      <div class="nsm-chips" style="display:flex;flex-wrap:wrap;gap:6px;margin:14px 0">${chips}</div>
      <p style="font-size:14px;color:var(--muted);line-height:1.7;margin:16px 0">
        ${esc(name)} is available through ${esc(cfg.siteName)} — part of Designer Wallcoverings.
        Order a free memo sample and our team will hand-hold your order from selection
        through installation.</p>
      <a class="hero-cta__btn" style="position:static;background:var(--accent);color:#fff"
         href="/buy/${esc(productSlug(p))}" target="_blank" rel="noopener noreferrer">
        Order Memo Sample</a>
      <p style="font-size:12px;color:var(--muted);margin:14px 0 0">
        Catalogued by Designer Wallcoverings ·
        <a href="/buy/${esc(productSlug(p))}" rel="noopener noreferrer" style="color:var(--accent)">View on the main store</a></p>
    </div>
  </div>
  ${related && related.length ? `
  <div class="controls" style="margin-top:38px"><label>Related Selections</label></div>
  <div class="grid">${related.map(card).join('')}</div>` : ''}
</div>
<style>@media(max-width:780px){.pdp-grid{grid-template-columns:1fr!important}}</style>
${footer(cfg)}
${nsModal(cfg)}
${script(cfg)}
</body></html>`;
}

module.exports = { homePage, catalogPage, aboutPage, infoPage, productPage, clean, esc, productSlug, scrubVendor, imgToken, proxyImg };