[object Object]

← back to NationalPaperHangers

Add P3 order-handoff to /measure: PDP deep-link prefill + host-allowlisted shop CTA

8c10bac989af2badb0ff84d41c8bf332bb16de09 · 2026-06-02 08:59:17 -0700 · SteveStudio2

- /measure reads sanitized ?sku/name/from/repeat/format/panelw/shop from the
  launch URL, shows a 'Sizing for <product>' banner, prefills the calculator,
  and points 'Shop the look' back at the exact PDP.
- shop return URL host-allowlisted (DW/wallco/philipperomano/novasuede +subs)
  to prevent open redirect; product strings EJS-escaped, prefill JSON escapes <.
- 7 route tests (tests/measure-route.test.js), wired into npm test. 50/50 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 8c10bac989af2badb0ff84d41c8bf332bb16de09
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue Jun 2 08:59:17 2026 -0700

    Add P3 order-handoff to /measure: PDP deep-link prefill + host-allowlisted shop CTA
    
    - /measure reads sanitized ?sku/name/from/repeat/format/panelw/shop from the
      launch URL, shows a 'Sizing for <product>' banner, prefills the calculator,
      and points 'Shop the look' back at the exact PDP.
    - shop return URL host-allowlisted (DW/wallco/philipperomano/novasuede +subs)
      to prevent open redirect; product strings EJS-escaped, prefill JSON escapes <.
    - 7 route tests (tests/measure-route.test.js), wired into npm test. 50/50 pass.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 AR_MEASURE_KIT_SPEC.md      | 14 ++++++--
 package.json                |  2 +-
 routes/public.js            | 55 +++++++++++++++++++++++++++--
 tests/measure-route.test.js | 85 +++++++++++++++++++++++++++++++++++++++++++++
 views/public/measure.ejs    | 50 +++++++++++++++++++++++++-
 5 files changed, 199 insertions(+), 7 deletions(-)

diff --git a/AR_MEASURE_KIT_SPEC.md b/AR_MEASURE_KIT_SPEC.md
index 2024b01..3b41caf 100644
--- a/AR_MEASURE_KIT_SPEC.md
+++ b/AR_MEASURE_KIT_SPEC.md
@@ -1,6 +1,6 @@
 # AR Wall Measurement Kit — Standalone Product Spec
 
-**Status:** P0 + P1 BUILT — local only, not deployed (2026-06-01).
+**Status:** P0 + P1 + P3 BUILT — local only, not deployed (P3 added 2026-06-02).
 Surface **A** (`/measure` on NPH), **both** handoffs (shop DW + find installer),
 manual width fallback for non-WebXR devices. Deploy is Steve-gated.
 **Brand:** National Paper Hangers (nationalpaperhangers.com)
@@ -95,8 +95,16 @@ coverage, waste factor.
   deductions, roll/panel calc (§4) in `public/js/measure-kit.js` (14 unit tests,
   all pass), live result card with plain-English buy line + both handoff CTAs.
 - **P2 — Save/share/export:** shareable link, PDF/email, deductions.
-- **P3 — Order handoff:** DW/wallco deep-links, repeat auto-fill from PDP,
-  "find an installer" tie-in to NPH core.
+- **P3 — Order handoff:** ✅ DONE. `/measure` reads sanitized product context
+  from the launch URL (`?sku=&name=&from=&repeat=&format=&panelw=&shop=`),
+  shows a "Sizing for <product>" banner, pre-fills the calculator (format /
+  repeat / panel width), and points the "Shop the look" CTA back at the exact
+  PDP. The `shop` return URL is **host-allowlisted** (designerwallcoverings.com,
+  wallco.ai, philipperomano.com, novasuede.com + subdomains) so the page can
+  never become an open redirect; product strings are EJS-escaped and the prefill
+  JSON block escapes `<`. 7 route tests in `tests/measure-route.test.js`
+  (deep-link prefill + the full security boundary), all pass. The
+  "find an installer" tie-in already links to NPH `/find`.
 - **P4 — (optional) widget/microsite:** only if option B or C is chosen.
 
 Deploy of anything customer-facing is gated on explicit Steve approval.
diff --git a/package.json b/package.json
index e0bbbde..83c7aa8 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
     "dev": "nodemon server.js",
     "schema": "psql $PGDATABASE < db/schema.sql",
     "seed": "psql $PGDATABASE < db/seed.sql",
-    "test": "node --test tests/smoke.test.js tests/compliance.test.js tests/measure-kit.test.js",
+    "test": "node --test tests/smoke.test.js tests/compliance.test.js tests/measure-kit.test.js tests/measure-route.test.js",
     "test:e2e": "node tests/run-e2e.js",
     "test:all": "npm test && npm run test:e2e",
     "dm-drafts": "node scripts/generate-ig-dm-drafts.js",
diff --git a/routes/public.js b/routes/public.js
index ed2cb70..0f7eb73 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -1038,10 +1038,61 @@ router.get('/for-installers', (req, res) => {
 
 // AR Wall Measurement Kit — phone-sensor wall measure + roll/panel calculator
 // for designers & DIYers. Reuses the /book RoomMeasure engine; no server data.
+//
+// P3 order-handoff: when launched from a DW / wallco product page, the link
+// carries product context (?sku=&name=&repeat=&format=&panelw=&shop=&from=).
+// We sanitize it, pre-fill the calculator, and point "Shop the look" back at
+// the exact PDP. The return URL is host-allowlisted so the page can never be
+// turned into an open redirect.
+const MEASURE_SHOP_HOSTS = [
+  'designerwallcoverings.com',
+  'wallco.ai',
+  'philipperomano.com',
+  'novasuede.com'
+];
+const MEASURE_FORMATS = ['us_standard', 'euro', 'panels'];
+
+// Accept only http(s) URLs whose hostname IS or ends with an allowlisted
+// brand domain. Anything else (javascript:, other hosts, garbage) → null.
+function sanitizeShopUrl(raw) {
+  if (!raw || typeof raw !== 'string' || raw.length > 2048) return null;
+  let u;
+  try { u = new URL(raw); } catch (_) { return null; }
+  if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
+  const host = u.hostname.toLowerCase();
+  const ok = MEASURE_SHOP_HOSTS.some(d => host === d || host.endsWith('.' + d));
+  return ok ? u.toString() : null;
+}
+
+function clampNum(raw, min, max) {
+  const n = parseFloat(raw);
+  if (!isFinite(n)) return null;
+  return Math.min(max, Math.max(min, n));
+}
+
 router.get('/measure', (req, res) => {
+  const q = req.query || {};
+  const shop = sanitizeShopUrl(q.shop);
+  // String fields are echoed via EJS <%= %> (HTML-escaped) and serialized into
+  // a JSON <script> block (not executable), so no further escaping is needed
+  // here — just bound the length to keep the page sane.
+  const str = (v) => (typeof v === 'string' && v.trim()) ? v.trim().slice(0, 120) : null;
+  const product = {
+    sku: str(q.sku),
+    name: str(q.name) || str(q.product),
+    from: str(q.from),
+    shop,
+    repeat: clampNum(q.repeat, 0, 120),
+    panelw: clampNum(q.panelw, 6, 60),
+    format: MEASURE_FORMATS.includes(q.format) ? q.format : null
+  };
+  const hasProduct = !!(product.sku || product.name || product.shop ||
+                        product.repeat != null || product.format);
+  const titleSuffix = product.name ? ` for ${product.name}` : '';
   res.render('public/measure', {
-    title: 'Measure your walls — AR Wall Measurement Kit · National Paper Hangers',
-    metaDescription: 'Measure any wall with your phone — no tape measure — then get an instant rolls-or-panels estimate for your wallpaper project. Free tool from National Paper Hangers.'
+    title: `Measure your walls${titleSuffix} — AR Wall Measurement Kit · National Paper Hangers`,
+    metaDescription: 'Measure any wall with your phone — no tape measure — then get an instant rolls-or-panels estimate for your wallpaper project. Free tool from National Paper Hangers.',
+    product: hasProduct ? product : null
   });
 });
 
diff --git a/tests/measure-route.test.js b/tests/measure-route.test.js
new file mode 100644
index 0000000..464f255
--- /dev/null
+++ b/tests/measure-route.test.js
@@ -0,0 +1,85 @@
+// /measure route tests — P3 product-handoff deep-link parsing + the security
+// boundary on the "shop" return URL. Pure render assertions via supertest; no
+// DB writes. Pairs with measure-kit.test.js (the pure calculator brain).
+'use strict';
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const supertest = require('supertest');
+const app = require('./app');
+
+const request = supertest(app);
+
+test('GET /measure renders the bare tool (no product context)', async () => {
+  const r = await request.get('/measure');
+  assert.equal(r.status, 200);
+  assert.ok(r.text.includes('Measure any wall with your phone'), 'hero present');
+  // No JSON product element when there is no product context. (The inline JS
+  // still references the id, so assert the actual element tag is absent.)
+  assert.ok(!r.text.includes('id="mk-product-data"'), 'no product JSON block');
+  assert.ok(r.text.includes('Shop the look — Designer Wallcoverings'), 'default shop CTA');
+});
+
+test('GET /measure?... renders a product banner + prefill JSON from a DW deep-link', async () => {
+  const r = await request.get('/measure').query({
+    sku: 'DW-1234', name: 'Grasscloth Natural', from: 'Designer Wallcoverings',
+    repeat: '18', format: 'us_standard',
+    shop: 'https://designerwallcoverings.com/products/grasscloth-natural'
+  });
+  assert.equal(r.status, 200);
+  assert.ok(r.text.includes('id="mk-product-data"'), 'product JSON block present');
+  assert.ok(r.text.includes('Grasscloth Natural'), 'product name in banner');
+  assert.ok(r.text.includes('SKU DW-1234'), 'sku chip');
+  assert.ok(r.text.includes('18&#34; pattern repeat') || r.text.includes('18" pattern repeat'),
+    'repeat chip');
+  assert.ok(r.text.includes('products/grasscloth-natural'), 'shop CTA links back to the PDP');
+  assert.ok(r.text.includes('Shop Grasscloth Natural'), 'shop CTA labelled with product');
+  assert.ok(r.text.includes('Measure your walls for Grasscloth Natural'), 'title suffix');
+  assert.ok(r.text.includes('"format":"us_standard"'), 'format serialized for prefill');
+  assert.ok(r.text.includes('"repeat":18'), 'repeat serialized for prefill');
+});
+
+test('GET /measure rejects a non-allowlisted shop host (no open redirect)', async () => {
+  const r = await request.get('/measure').query({
+    name: 'Evil', shop: 'https://evil.example.com/phish'
+  });
+  assert.equal(r.status, 200);
+  assert.ok(!r.text.includes('evil.example.com'), 'evil host never echoed');
+  assert.ok(r.text.includes('Shop the look — Designer Wallcoverings'), 'falls back to default CTA');
+});
+
+test('GET /measure rejects a javascript: scheme shop URL', async () => {
+  const r = await request.get('/measure').query({
+    name: 'XSS', shop: 'javascript:alert(1)'
+  });
+  assert.equal(r.status, 200);
+  assert.ok(!r.text.includes('javascript:alert'), 'js scheme never echoed');
+});
+
+test('GET /measure allows brand subdomains (www.wallco.ai)', async () => {
+  const r = await request.get('/measure').query({
+    name: 'Mural', format: 'panels', shop: 'https://www.wallco.ai/d/123'
+  });
+  assert.equal(r.status, 200);
+  assert.ok(r.text.includes('www.wallco.ai/d/123'), 'subdomain shop URL allowed');
+  assert.ok(r.text.includes('"format":"panels"'), 'panels format serialized');
+});
+
+test('GET /measure escapes < in the product JSON block (no </script> breakout)', async () => {
+  const r = await request.get('/measure').query({
+    name: '</script><b>x', shop: 'https://wallco.ai/x'
+  });
+  assert.equal(r.status, 200);
+  // The raw breakout string must not survive verbatim inside the JSON block.
+  assert.ok(!r.text.includes('</script><b>x'), 'no raw </script> breakout');
+  assert.ok(r.text.includes('\\u003c'), 'less-than escaped to \\u003c');
+});
+
+test('GET /measure clamps out-of-range repeat/panel values', async () => {
+  const r = await request.get('/measure').query({
+    name: 'Clamp', repeat: '9999', panelw: '1', format: 'panels'
+  });
+  assert.equal(r.status, 200);
+  assert.ok(r.text.includes('"repeat":120'), 'repeat clamped to 120');
+  assert.ok(r.text.includes('"panelw":6'), 'panel width clamped to 6');
+});
diff --git a/views/public/measure.ejs b/views/public/measure.ejs
index f99bfc4..1f91559 100644
--- a/views/public/measure.ejs
+++ b/views/public/measure.ejs
@@ -68,6 +68,13 @@
   .mk-installer{background:var(--ink,#0a0a0a);color:#fff}
   .mk-disclaimer{font-size:11px;color:var(--muted,#888);line-height:1.5;margin:14px 0 0;border-top:1px solid var(--border,#eee);padding-top:12px}
   .mk-privacy{font-size:12px;color:var(--muted,#888);text-align:center;max-width:560px;margin:18px auto 0;line-height:1.5}
+
+  /* ── P3 product handoff banner ────────────────────────────────────── */
+  .mk-product{display:flex;align-items:center;gap:12px;border:1px solid var(--border,#d4d2c8);border-left:4px solid #f5c451;border-radius:10px;padding:12px 16px;margin:0 0 16px;background:var(--card-bg,#fff)}
+  .mk-product .mk-product-body{flex:1;min-width:0}
+  .mk-product .mk-product-kicker{font-size:11px;letter-spacing:0.08em;text-transform:uppercase;color:var(--muted,#888);margin:0 0 2px}
+  .mk-product .mk-product-name{font-size:15px;font-weight:600;color:var(--ink,#0a0a0a);margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  .mk-product .mk-product-meta{font-size:12px;color:var(--muted,#777);margin:2px 0 0}
 </style>
 
 <section class="mk-hero">
@@ -79,6 +86,23 @@
 </section>
 
 <div class="mk-wrap">
+  <% if (typeof product !== 'undefined' && product) { %>
+  <div class="mk-product" id="mk-product">
+    <span aria-hidden="true" style="font-size:22px">🧮</span>
+    <div class="mk-product-body">
+      <p class="mk-product-kicker">Sizing for<%= product.from ? ' · ' + product.from : '' %></p>
+      <p class="mk-product-name"><%= product.name || product.sku || 'Selected product' %></p>
+      <p class="mk-product-meta"><%
+        var bits = [];
+        if (product.sku) bits.push('SKU ' + product.sku);
+        if (product.format === 'panels') bits.push('Mural / panel goods');
+        else if (product.format === 'euro') bits.push('Euro roll');
+        else if (product.format === 'us_standard') bits.push('US standard roll');
+        if (product.repeat != null && product.repeat > 0) bits.push(product.repeat + '" pattern repeat');
+      %><%= bits.join(' · ') || 'Measure your walls below to size this product.' %></p>
+    </div>
+  </div>
+  <% } %>
   <div class="mk-walls" id="mk-walls"></div>
   <button type="button" class="mk-add-wall" id="mk-add-wall">+ Add another wall</button>
 
@@ -110,7 +134,11 @@
     <p class="mk-perwall" id="mk-perwall"></p>
     <div id="mk-flags"></div>
     <div class="mk-handoff">
+      <% if (typeof product !== 'undefined' && product && product.shop) { %>
+      <a class="mk-shop" href="<%= product.shop %>" target="_blank" rel="noopener noreferrer">Shop <%= product.name || 'this look' %><%= product.from ? ' — ' + product.from : '' %></a>
+      <% } else { %>
       <a class="mk-shop" href="https://designerwallcoverings.com" target="_blank" rel="noopener noreferrer">Shop the look — Designer Wallcoverings</a>
+      <% } %>
       <a class="mk-installer" href="/find">Find an installer near you</a>
     </div>
     <p class="mk-disclaimer">These figures are estimates based on standard trade rules of thumb and the dimensions entered. Pattern match, wall condition, and trim losses vary — <strong>always confirm quantities with your installer or the manufacturer before ordering.</strong> National Paper Hangers is a referral and lead-coordination service and does not warrant installation work.</p>
@@ -119,6 +147,9 @@
   <p class="mk-privacy">🔒 Privacy: measurement happens entirely on your device. Camera and motion access are only requested when you tap to measure, AR camera frames never leave your phone, and nothing you enter here is sent to or stored on our servers.</p>
 </div>
 
+<% if (typeof product !== 'undefined' && product) { %>
+<script type="application/json" id="mk-product-data"><%- JSON.stringify(product).replace(/</g, '\\u003c') %></script>
+<% } %>
 <script src="/js/room-measure.js" defer></script>
 <script src="/js/measure-kit.js" defer></script>
 <script>
@@ -130,6 +161,22 @@
   var $ = function (id) { return document.getElementById(id); };
   var wallsEl = $('mk-walls');
 
+  // ── P3: product context from a DW/wallco PDP deep-link ──────────────
+  // Pre-fills format / repeat / panel width so the buy line matches the
+  // product the shopper came from. Parsed from a non-executable JSON block.
+  var product = null;
+  try {
+    var pd = $('mk-product-data');
+    if (pd) product = JSON.parse(pd.textContent || 'null');
+  } catch (_) { product = null; }
+
+  function applyProduct() {
+    if (!product) return;
+    if (product.format) $('mk-format').value = product.format;
+    if (product.repeat != null) $('mk-repeat').value = product.repeat;
+    if (product.panelw != null) $('mk-panelw').value = product.panelw;
+  }
+
   function num(v) { var n = parseFloat(v); return isFinite(n) ? n : 0; }
   function fmtFt(n) { return (Math.round(n * 10) / 10) + ' ft'; }
 
@@ -302,7 +349,8 @@
     });
   }
 
-  addWall();   // start with one wall ready
+  applyProduct();   // prefill format/repeat/panel from PDP deep-link, if any
+  addWall();        // start with one wall ready
 })();
 </script>
 

← 313ab55 Add standalone AR Wall Measurement Kit at /measure (P0+P1)  ·  back to NationalPaperHangers  ·  Add measure_jobs schema (migration 023) — Council Idea #5 le 5ea9d11 →