[object Object]

← back to Apartmentwallpaper

Add SSR /products/:handle PDPs (GMC-valid) + on-site rewire + full sitemap

299857d20f7ff88453ad221a0a3d9cedffc32cbf · 2026-09-10 12:13:27 -0700 · Steve Abrams

- GET /products/:handle: dual-key resolver (raw handle/sku -> redacted slug),
  404 on unknown, server-rendered PDP (hero, title, price, availability,
  description, body_html, .buy-btn[data-pid], Sample CTA, canonical, OG meta).
- Product JSON-LD (@graph: Organization, WebSite, Product+Offer, BreadcrumbList).
  brand ALWAYS 'Designer Wallcoverings'; price+availability == visible HTML
  (GMC price-parity). Vendor never rendered.
- /sample/:handle now redirects ON-SITE to /products/<handle> (kills the old
  designerwallcoverings.com redirect — Steve's #1 rule).
- index.html: grid + rail cards now link to /products/<handle> (was # / /sample/).
- sitemap.xml: static pages + one <url> per served product (1023).
- Reuses public/buybutton.js unchanged; PDP price selection mirrors its exact
  nonSample[0] variant pick.
Verified locally: 25/25 random PDPs 200, priced, 0 leaks, numeric pids; JSON-LD
parses; parity match; unknown->404; /sample stays on-site.

Files touched

Diff

commit 299857d20f7ff88453ad221a0a3d9cedffc32cbf
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 12:13:27 2026 -0700

    Add SSR /products/:handle PDPs (GMC-valid) + on-site rewire + full sitemap
    
    - GET /products/:handle: dual-key resolver (raw handle/sku -> redacted slug),
      404 on unknown, server-rendered PDP (hero, title, price, availability,
      description, body_html, .buy-btn[data-pid], Sample CTA, canonical, OG meta).
    - Product JSON-LD (@graph: Organization, WebSite, Product+Offer, BreadcrumbList).
      brand ALWAYS 'Designer Wallcoverings'; price+availability == visible HTML
      (GMC price-parity). Vendor never rendered.
    - /sample/:handle now redirects ON-SITE to /products/<handle> (kills the old
      designerwallcoverings.com redirect — Steve's #1 rule).
    - index.html: grid + rail cards now link to /products/<handle> (was # / /sample/).
    - sitemap.xml: static pages + one <url> per served product (1023).
    - Reuses public/buybutton.js unchanged; PDP price selection mirrors its exact
      nonSample[0] variant pick.
    Verified locally: 25/25 random PDPs 200, priced, 0 leaks, numeric pids; JSON-LD
    parses; parity match; unknown->404; /sample stays on-site.
---
 public/index.html |   4 +-
 server.js         | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++---
 2 files changed, 147 insertions(+), 8 deletions(-)

diff --git a/public/index.html b/public/index.html
index 47fcf55..4421230 100644
--- a/public/index.html
+++ b/public/index.html
@@ -717,7 +717,7 @@ async function loadGridPage() {
   const grid = document.getElementById('grid');
   for (const p of data.items) {
     const a = document.createElement('a');
-    a.className = 'card'; a.href = '#';
+    a.className = 'card'; a.href = '/products/' + encodeURIComponent(p.handle_display || p.handle || p.sku);
     a.onclick = (e) => {
       e.preventDefault();
       // Touch devices have no hover — 1st tap reveals the card's info overlay,
@@ -1516,7 +1516,7 @@ loadGridPage();
     const curated = items.filter((_, i) => i % 5 === 3).slice(0, 12);
     function card(p) {
       const url = safeImg((p.image_url || '').replace(/(_\d+x\d*)(\.(jpg|jpeg|png|webp|gif))(\?|$)/i, '$2$4'));
-      return '<a class="rail-card" href="/sample/' + encodeURIComponent(p.handle_display || p.handle || p.sku) + '">' +
+      return '<a class="rail-card" href="/products/' + encodeURIComponent(p.handle_display || p.handle || p.sku) + '">' +
         '<img src="' + escAttr(url) + '" alt="' + escAttr(p.title) + '" loading="lazy">' +
         '<div class="rc-title">' + escAttr(((p.title || '').split('|')[0] || '').trim()) + '</div>' +
         '<div class="rc-meta">' + escAttr(p.vendor) + '</div>' +
diff --git a/server.js b/server.js
index d4e7117..d79d606 100644
--- a/server.js
+++ b/server.js
@@ -235,12 +235,147 @@ function buildSampleDisplayMap() {
   console.log(`[apartmentwallpaper] /sample dual-key map: ${map.size} display slugs, ${collided.size} collisions (raw-only)`);
 }
 
+// Dual-key product resolver — raw handle/sku first (old bookmarks + buy path),
+// then the redacted display slug. Shared by /sample/:handle and /products/:handle.
+function resolveProduct(key) {
+  return PRODUCTS_NICHE.find(x => x.handle === key || x.sku === key)
+    || SAMPLE_DISPLAY.get(key) || null;
+}
+
+// /sample/:handle — stays ON-SITE. Never redirect to designerwallcoverings.com
+// (Steve's #1 rule). Sends the visitor to the on-site PDP with the sample anchor;
+// the client card-override opens the Sample modal directly when JS is on.
 app.get('/sample/:handle', (req, res) => {
-  const key = req.params.handle;
-  const p = PRODUCTS_NICHE.find(x => x.handle === key || x.sku === key) // raw first — old behavior intact
-    || SAMPLE_DISPLAY.get(key);                                        // then the redacted display form
+  const p = resolveProduct(req.params.handle);
   if (!p) return res.status(404).send('Not found');
-  res.redirect(302, p.product_url || `${DW_SHOPIFY}/products/${encodeURIComponent(p.handle)}#sample`);
+  res.redirect(302, `/products/${encodeURIComponent(p.handle)}#sample`);
+});
+
+// ---- Per-product PDP (SSR) — real, GMC-valid product pages on our own domain --
+function pdpEsc(s) {
+  return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
+}
+// Image allowlist mirrors the client safeImg (DW Shopify CDN + same-origin /img proxy).
+function pdpImg(u) {
+  return (/^https:\/\/(?:cdn\.shopify\.com|designerwallcoverings\.com)\//.test(u || '')
+    || /^\/img\/[A-Za-z0-9._-]+$/.test(u || '')) ? u : '/hero-bg.jpg';
+}
+function renderPDP(p) {
+  const title = p.title || p.pattern_name || 'Peel & Stick Wallpaper';
+  const sku = p.sku || p.handle;
+  const img = pdpImg(p.image_url);
+  const price = (typeof p.price === 'number' && p.price > 0) ? p.price : null;
+  const priceStr = price != null ? price.toFixed(2) : null;
+  const inStock = p.availability !== 'out of stock';
+  const availLabel = inStock ? 'In stock' : 'Out of stock';
+  const availSchema = inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock';
+  const desc = (p.description || '').trim();
+  const metaDesc = (desc || `${title} — removable peel-and-stick wallcovering. Renter-friendly, no-damage install.`).slice(0, 300);
+  const body = (p.body_html && String(p.body_html).trim()) || `<p>${pdpEsc(desc)}</p>`;
+  const canonical = `https://apartmentwallpaper.com/products/${encodeURIComponent(p.handle)}`;
+
+  // Product JSON-LD (structured-data-jsonld shape). brand ALWAYS "Designer
+  // Wallcoverings"; price + availability MUST match the visible HTML above.
+  const graph = [
+    { '@type': 'Organization', '@id': 'https://apartmentwallpaper.com/#org', name: 'Designer Wallcoverings', url: 'https://apartmentwallpaper.com' },
+    { '@type': 'WebSite', '@id': 'https://apartmentwallpaper.com/#website', name: 'Apartment Wallpaper', url: 'https://apartmentwallpaper.com', publisher: { '@id': 'https://apartmentwallpaper.com/#org' } },
+    {
+      '@type': 'Product', name: title, sku, image: [img],
+      description: metaDesc, brand: { '@type': 'Brand', name: 'Designer Wallcoverings' },
+      ...(price != null ? { offers: { '@type': 'Offer', price: priceStr, priceCurrency: 'USD', availability: availSchema, url: canonical, itemCondition: 'https://schema.org/NewCondition' } } : {}),
+    },
+    { '@type': 'BreadcrumbList', itemListElement: [
+      { '@type': 'ListItem', position: 1, name: 'Home', item: 'https://apartmentwallpaper.com/' },
+      { '@type': 'ListItem', position: 2, name: title, item: canonical },
+    ] },
+  ];
+  const ld = JSON.stringify({ '@context': 'https://schema.org', '@graph': graph }).replace(/</g, '\\u003c');
+
+  return `<!doctype html>
+<html lang="en"><head>
+<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5278231299883833" crossorigin="anonymous"></script>
+<script async src="https://www.googletagmanager.com/gtag/js?id=G-KP5P2XHN8Y"></script>
+<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','G-KP5P2XHN8Y');</script>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${pdpEsc(title)} — Apartment Wallpaper</title>
+<meta name="description" content="${pdpEsc(metaDesc)}">
+<link rel="canonical" href="${pdpEsc(canonical)}">
+<meta property="og:type" content="product">
+<meta property="og:title" content="${pdpEsc(title)}">
+<meta property="og:description" content="${pdpEsc(metaDesc)}">
+<meta property="og:image" content="${pdpEsc(img)}">
+<meta property="og:url" content="${pdpEsc(canonical)}">
+${price != null ? `<meta property="product:price:amount" content="${priceStr}"><meta property="product:price:currency" content="USD">` : ''}
+<script type="application/ld+json">${ld}</script>
+<link rel="stylesheet" href="/page-theme.css">
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:ui-sans-serif,system-ui;background:#F4F1EC;color:#1A2B40;line-height:1.6;min-height:100vh}
+.pdp{max-width:1080px;margin:0 auto;padding:96px 24px 64px}
+.crumb{font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:#7a7268;margin-bottom:22px}
+.crumb a{color:#7a7268;text-decoration:none} .crumb a:hover{color:#1A2B40}
+.grid{display:grid;grid-template-columns:1.1fr 1fr;gap:48px;align-items:start}
+@media(max-width:760px){.grid{grid-template-columns:1fr;gap:28px}.pdp{padding:80px 18px 48px}}
+.hero{width:100%;aspect-ratio:1/1;object-fit:cover;background:#e8e4dc;border:1px solid #e5e0d6}
+.eyebrow{font-size:10px;letter-spacing:0.22em;text-transform:uppercase;color:#7CC288;margin-bottom:14px}
+h1{font-family:Georgia,serif;font-style:italic;font-size:40px;line-height:1.12;letter-spacing:-0.01em;margin-bottom:16px}
+@media(max-width:760px){h1{font-size:30px}}
+.sku{font-size:12px;letter-spacing:0.12em;text-transform:uppercase;color:#8a8278;margin-bottom:20px}
+.price{font-size:26px;font-weight:600;margin-bottom:8px}
+.avail{font-size:12px;letter-spacing:0.1em;text-transform:uppercase;margin-bottom:24px}
+.avail.in{color:#3f8f52}.avail.out{color:#a15}
+.desc{color:#3a322c;margin-bottom:28px;font-size:15px}
+.body{color:#3a322c;font-size:14px;margin-bottom:28px}.body p{margin-bottom:12px}
+.actions{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:28px}
+.buy-btn,.sample-cta{padding:14px 26px;font:700 11px ui-sans-serif;letter-spacing:0.2em;text-transform:uppercase;border:0;cursor:pointer;border-radius:6px;text-decoration:none;display:inline-block}
+.buy-btn{background:#1a1a1a;color:#fff}.buy-btn:hover{background:#333}
+.aw-no-buy .buy-btn{display:none}
+.sample-cta{background:transparent;color:#1A2B40;border:1px solid #1A2B40}.sample-cta:hover{background:#1A2B40;color:#fff}
+nav.foot{margin-top:56px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.10);font-size:11px;letter-spacing:0.18em;text-transform:uppercase}
+nav.foot a{margin-right:22px;text-decoration:none;color:#1A2B40}
+</style>
+</head><body data-pdp>
+<div class="pdp">
+  <div class="crumb"><a href="/">Apartment Wallpaper</a> &nbsp;/&nbsp; <a href="/">Browse</a> &nbsp;/&nbsp; ${pdpEsc(title)}</div>
+  <div class="grid">
+    <img class="hero" src="${pdpEsc(img)}" alt="${pdpEsc(title)}" width="1080" height="1080">
+    <div>
+      <div class="eyebrow">Designer Wallcoverings · Peel &amp; Stick</div>
+      <h1>${pdpEsc(title)}</h1>
+      <div class="sku">SKU ${pdpEsc(sku)}</div>
+      ${price != null ? `<div class="price">$${priceStr}</div>` : ''}
+      <div class="avail ${inStock ? 'in' : 'out'}">${availLabel}</div>
+      ${desc ? `<div class="desc">${pdpEsc(desc)}</div>` : ''}
+      <div class="actions">
+        ${p.shopify_id ? `<button class="buy-btn" data-pid="${pdpEsc(p.shopify_id)}">Add to Cart</button>` : ''}
+        <a class="sample-cta" href="/sample/${encodeURIComponent(p.handle)}" onclick="event.preventDefault();awOpenSample&&awOpenSample(this)" data-sku="${pdpEsc(sku)}" data-title="${pdpEsc(title)}" data-img="${pdpEsc(img)}">Order a Sample</a>
+      </div>
+      <div class="body">${body}</div>
+    </div>
+  </div>
+  <nav class="foot">
+    <a href="/">Browse</a><a href="/history">History</a><a href="/vocabulary">Vocabulary</a><a href="/sourcing">Sourcing</a><a href="/care">Care</a><a href="/trade">Trade</a><a href="/about">About</a>
+  </nav>
+</div>
+<script>
+// Sample CTA — mailto fallback (no client sample-modal on the SSR PDP page).
+function awOpenSample(el){
+  var sku=el.getAttribute('data-sku'), t=el.getAttribute('data-title');
+  location.href='mailto:info@apartmentwallpaper.com?subject='+encodeURIComponent('Sample request: '+t+' ('+sku+')')
+    +'&body='+encodeURIComponent('I would like to request a sample of '+t+' (SKU '+sku+').');
+}
+</script>
+<script src="/dw-header.js" defer></script>
+<script src="/page-theme.js" defer></script>
+<script src="/aw-config.js"></script>
+<script src="/buybutton.js" defer></script>
+</body></html>`;
+}
+
+app.get('/products/:handle', (req, res) => {
+  const p = resolveProduct(req.params.handle);
+  if (!p) return res.status(404).type('html').send('<!doctype html><meta charset=utf-8><title>Not found</title><body style="font-family:ui-sans-serif;padding:80px;text-align:center"><h1>404</h1><p>That pattern isn\'t here. <a href="/">Browse the collection</a>.</p>');
+  res.type('html').send(renderPDP(p));
 });
 
 // /about → served by the "short history" page (no dedicated about.html exists)
@@ -266,10 +401,14 @@ Sitemap: https://apartmentwallpaper.com/sitemap.xml
 `);
 });
 app.get('/sitemap.xml', (req, res) => {
-  const urls = ['/'];
+  const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;');
+  const staticUrls = ['/', '/history', '/vocabulary', '/sourcing', '/care', '/trade', '/about']
+    .map(u => `  <url><loc>https://apartmentwallpaper.com${u}</loc><changefreq>weekly</changefreq></url>`);
+  const productUrls = PRODUCTS_NICHE.map(p =>
+    `  <url><loc>https://apartmentwallpaper.com/products/${esc(encodeURIComponent(p.handle))}</loc><changefreq>weekly</changefreq></url>`);
   const xml = `<?xml version="1.0" encoding="UTF-8"?>
 <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
-${urls.map(u => `  <url><loc>https://apartmentwallpaper.com${u}</loc><changefreq>weekly</changefreq></url>`).join('\n')}
+${staticUrls.concat(productUrls).join('\n')}
 </urlset>`;
   res.type('application/xml').send(xml);
 });

← dce8f0e Catalog regen from dw_unified mirror + Admin API price/avail  ·  back to Apartmentwallpaper  ·  Scaffold .deploy.conf for apartmentwallpaper Kamatera target 01aef2d →