← 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
M public/index.htmlM server.js
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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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> / <a href="/">Browse</a> / ${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 & 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, '&').replace(/</g, '<');
+ 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 →