[object Object]

← back to NationalPaperHangers

Add camera capture & measure step to the /book wizard

d2f12ee2867c125a0265c181a3c9d44e280ef7a2 · 2026-05-18 16:43:50 -0700 · SteveStudio2

Per room the customer photographs a wall, drags a box to frame it, and
confirms the ceiling height — wall width is derived from the box aspect
(estimate-then-confirm, never a silent measurement). Optional room
walkthrough video. Optional 'preview your own wallpaper': upload the
wallpaper image + its exact panel width and vertical repeat, and it tiles
onto the wall photo at true real-world scale. Everything serializes into
the hidden room_captures field that posts with the booking. Measured walls
auto-total into the square-feet field. Verified via a 9-assertion
Playwright run (upload, box-drag aspect math, wallpaper preview, multi-room).

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

Files touched

Diff

commit d2f12ee2867c125a0265c181a3c9d44e280ef7a2
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Mon May 18 16:43:50 2026 -0700

    Add camera capture & measure step to the /book wizard
    
    Per room the customer photographs a wall, drags a box to frame it, and
    confirms the ceiling height — wall width is derived from the box aspect
    (estimate-then-confirm, never a silent measurement). Optional room
    walkthrough video. Optional 'preview your own wallpaper': upload the
    wallpaper image + its exact panel width and vertical repeat, and it tiles
    onto the wall photo at true real-world scale. Everything serializes into
    the hidden room_captures field that posts with the booking. Measured walls
    auto-total into the square-feet field. Verified via a 9-assertion
    Playwright run (upload, box-drag aspect math, wallpaper preview, multi-room).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 public/js/room-capture.js | 249 ++++++++++++++++++++++++++++++++++++++++++++++
 views/public/book.ejs     |  96 ++++++++++++++++++
 2 files changed, 345 insertions(+)

diff --git a/public/js/room-capture.js b/public/js/room-capture.js
new file mode 100644
index 0000000..86cd7d2
--- /dev/null
+++ b/public/js/room-capture.js
@@ -0,0 +1,249 @@
+/* Room capture & measure — /book quote brief (UX #6).
+ *
+ * Per room: photograph a wall, drag a box to frame it, confirm ceiling
+ * height -> wall width is derived from the box aspect, optionally upload
+ * the customer's own wallpaper (exact panel width + vertical repeat) to
+ * preview it tiled on the wall at TRUE scale. Everything serializes into
+ * the hidden `room_captures` field that calendar-consumer.js posts with
+ * the booking, so the installer quotes from real footage + dimensions.
+ *
+ * The box's on-screen aspect ratio equals the wall's real aspect ratio
+ * when the wall is shot roughly fronto-parallel (the "~4 ft back, phone
+ * level" guidance) — so width = confirmed-height x box-aspect. It's an
+ * estimate the customer then confirms, never a silent measurement.
+ */
+(function () {
+  var root = document.querySelector('[data-room-capture]');
+  if (!root) return;
+  var tpl = document.getElementById('rc-room-tpl');
+  var roomsEl = root.querySelector('[data-rc-rooms]');
+  var field = root.querySelector('[data-rc-field]');
+  var addBtn = root.querySelector('[data-rc-add]');
+  var sqftInput = document.querySelector('input[name="square_feet"]');
+  var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
+
+  var rooms = [];
+
+  function num(v) { var n = parseFloat(v); return isFinite(n) ? n : 0; }
+  function round(n, step) { return Math.round(n / step) * step; }
+
+  function uploadFile(file) {
+    var fd = new FormData();
+    fd.append('file', file);
+    return fetch('/api/booking-media', {
+      method: 'POST',
+      headers: csrf ? { 'X-CSRF-Token': csrf } : {},
+      body: fd
+    }).then(function (r) { return r.json(); }).then(function (j) {
+      if (!j || !j.ok) throw new Error((j && j.error) || 'Upload failed');
+      return j;
+    });
+  }
+
+  // Collect every room with a photo into the hidden field, and total the
+  // measured walls into the manual square-feet input (customer can override).
+  function serialize() {
+    var out = [], totalSqFt = 0;
+    rooms.forEach(function (r) {
+      if (!r.photoUrl) return;
+      var w = num(r.el.querySelector('.rc-width').value);
+      var h = num(r.el.querySelector('.rc-height').value);
+      if (w > 0 && h > 0) totalSqFt += w * h;
+      var entry = {
+        room: r.el.querySelector('.rc-room-label').value.trim() || 'Room',
+        wall_width_ft: w || null,
+        wall_height_ft: h || null,
+        photo_url: r.photoUrl,
+        video_url: r.videoUrl || null,
+        wallpaper: null
+      };
+      var wpW = num(r.el.querySelector('.rc-wp-width').value);
+      var wpR = num(r.el.querySelector('.rc-wp-repeat').value);
+      if (r.wallpaperUrl && wpW > 0 && wpR > 0) {
+        entry.wallpaper = {
+          image_url: r.wallpaperUrl,
+          width_in: wpW,
+          repeat_in: wpR,
+          match_type: r.el.querySelector('.rc-wp-match').value
+        };
+      }
+      out.push(entry);
+    });
+    field.value = JSON.stringify(out);
+    if (sqftInput && totalSqFt > 0) sqftInput.value = Math.round(totalSqFt);
+  }
+
+  function applyBox(r) {
+    var el = r.el.querySelector('.rc-box');
+    el.style.left = (r.box.l * 100) + '%';
+    el.style.top = (r.box.t * 100) + '%';
+    el.style.width = (r.box.w * 100) + '%';
+    el.style.height = (r.box.h * 100) + '%';
+  }
+
+  function recompute(r) {
+    var wrap = r.el.querySelector('.rc-photo-wrap').getBoundingClientRect();
+    if (!wrap.width || !wrap.height) return;
+    var boxAspect = (r.box.w * wrap.width) / (r.box.h * wrap.height);
+    var h = num(r.el.querySelector('.rc-height').value);
+    if (h > 0 && boxAspect > 0) {
+      var w = round(h * boxAspect, 0.5);
+      r.el.querySelector('.rc-width').value = w;
+      r.el.querySelector('.rc-estimate').textContent =
+        'Estimated ' + w + ' ft wide x ' + h + ' ft tall (' +
+        Math.round(w * h) + ' sq ft) — confirm or adjust.';
+    }
+    renderWallpaper(r);
+    serialize();
+  }
+
+  // Tile the uploaded wallpaper onto the wall box at real-world scale:
+  // one panel spans (width_in/12 / wall_width_ft) of the box width; one
+  // vertical repeat spans (repeat_in/12 / wall_height_ft) of its height.
+  function renderWallpaper(r) {
+    var layer = r.el.querySelector('.rc-wallpaper-layer');
+    var note = r.el.querySelector('.rc-wp-note');
+    var wpW = num(r.el.querySelector('.rc-wp-width').value);
+    var wpR = num(r.el.querySelector('.rc-wp-repeat').value);
+    var wallW = num(r.el.querySelector('.rc-width').value);
+    var wallH = num(r.el.querySelector('.rc-height').value);
+    if (!r.wallpaperUrl) { layer.style.backgroundImage = ''; return; }
+    if (!(wpW > 0) || !(wpR > 0)) {
+      layer.style.backgroundImage = '';
+      note.classList.add('is-error');
+      note.textContent = 'Enter the exact panel width and vertical repeat to preview at true scale.';
+      return;
+    }
+    note.classList.remove('is-error');
+    note.textContent = 'Previewed at true scale — ' + wpW + '" panel, ' + wpR + '" repeat.';
+    if (!(wallW > 0) || !(wallH > 0)) { layer.style.backgroundImage = ''; return; }
+    var tileWpct = ((wpW / 12) / wallW) * 100;
+    var tileHpct = ((wpR / 12) / wallH) * 100;
+    layer.style.backgroundImage = 'url("' + r.wallpaperUrl + '")';
+    layer.style.backgroundSize = tileWpct.toFixed(2) + '% ' + tileHpct.toFixed(2) + '%';
+  }
+
+  // Corner-drag — axis-aligned rect, opposite corner stays anchored.
+  // Pointer events cover mouse + touch uniformly; capture keeps the drag
+  // alive when the finger leaves the handle.
+  function wireBox(r) {
+    var wrap = r.el.querySelector('.rc-photo-wrap');
+    r.el.querySelectorAll('.rc-handle').forEach(function (handle) {
+      var corner = handle.getAttribute('data-corner');
+      handle.addEventListener('pointerdown', function (e) {
+        e.preventDefault();
+        handle.setPointerCapture(e.pointerId);
+        function move(ev) {
+          var rect = wrap.getBoundingClientRect();
+          var x = Math.min(1, Math.max(0, (ev.clientX - rect.left) / rect.width));
+          var y = Math.min(1, Math.max(0, (ev.clientY - rect.top) / rect.height));
+          var b = r.box, right = b.l + b.w, bottom = b.t + b.h, MIN = 0.08;
+          if (corner === 'tl') { b.l = Math.min(x, right - MIN); b.t = Math.min(y, bottom - MIN); b.w = right - b.l; b.h = bottom - b.t; }
+          else if (corner === 'tr') { b.t = Math.min(y, bottom - MIN); b.w = Math.max(MIN, x - b.l); b.h = bottom - b.t; }
+          else if (corner === 'bl') { b.l = Math.min(x, right - MIN); b.w = right - b.l; b.h = Math.max(MIN, y - b.t); }
+          else { b.w = Math.max(MIN, x - b.l); b.h = Math.max(MIN, y - b.t); }
+          applyBox(r);
+          recompute(r);
+        }
+        function up() {
+          handle.releasePointerCapture(e.pointerId);
+          handle.removeEventListener('pointermove', move);
+          handle.removeEventListener('pointerup', up);
+          handle.removeEventListener('pointercancel', up);
+        }
+        handle.addEventListener('pointermove', move);
+        handle.addEventListener('pointerup', up);
+        handle.addEventListener('pointercancel', up);
+      });
+    });
+  }
+
+  function initBox(r) {
+    r.box = { l: 0.15, t: 0.10, w: 0.70, h: 0.80 };
+    applyBox(r);
+    recompute(r);
+  }
+
+  function wireRoom(r) {
+    var el = r.el;
+
+    el.querySelector('.rc-remove').addEventListener('click', function () {
+      rooms = rooms.filter(function (x) { return x !== r; });
+      el.remove();
+      serialize();
+    });
+
+    var photoInput = el.querySelector('.rc-photo-input');
+    var uploading = el.querySelector('.rc-uploading');
+    photoInput.addEventListener('change', function () {
+      var f = photoInput.files[0];
+      if (!f) return;
+      uploading.hidden = false;
+      uploading.classList.remove('is-error');
+      uploading.textContent = 'Uploading photo…';
+      // Display the stored URL (same-origin) rather than a blob: object URL —
+      // the page CSP is `img-src 'self' data: https:`, which excludes blob:.
+      uploadFile(f).then(function (j) {
+        r.photoUrl = j.url;
+        var img = el.querySelector('.rc-photo');
+        img.onload = function () { initBox(r); };
+        img.src = j.url;
+        uploading.hidden = true;
+        el.querySelector('.rc-stage').hidden = false;
+        el.querySelector('.rc-photo-cta').textContent = '📷 Retake wall photo';
+        if (img.complete && img.naturalWidth) initBox(r);
+        serialize();
+      }).catch(function (err) {
+        uploading.classList.add('is-error');
+        uploading.textContent = (err && err.message) || 'Upload failed — try again.';
+      });
+    });
+
+    var videoInput = el.querySelector('.rc-video-input');
+    videoInput.addEventListener('change', function () {
+      var f = videoInput.files[0];
+      if (!f) return;
+      var cta = el.querySelector('.rc-video-cta');
+      cta.textContent = 'Uploading video…';
+      uploadFile(f).then(function (j) {
+        r.videoUrl = j.url;
+        cta.textContent = '🎬 Walkthrough added ✓ — replace';
+        serialize();
+      }).catch(function () { cta.textContent = '🎬 Upload failed — tap to retry'; });
+    });
+
+    var wpInput = el.querySelector('.rc-wp-input');
+    wpInput.addEventListener('change', function () {
+      var f = wpInput.files[0];
+      if (!f) return;
+      var cta = el.querySelector('.rc-wp-cta');
+      cta.textContent = 'Uploading…';
+      uploadFile(f).then(function (j) {
+        r.wallpaperUrl = j.url;
+        cta.textContent = 'Wallpaper added ✓ — replace';
+        renderWallpaper(r);
+        serialize();
+      }).catch(function () { cta.textContent = 'Upload failed — tap to retry'; });
+    });
+
+    el.querySelector('.rc-height').addEventListener('input', function () { recompute(r); });
+    el.querySelector('.rc-width').addEventListener('input', function () { renderWallpaper(r); serialize(); });
+    el.querySelector('.rc-room-label').addEventListener('input', serialize);
+    ['.rc-wp-width', '.rc-wp-repeat', '.rc-wp-match'].forEach(function (sel) {
+      el.querySelector(sel).addEventListener('input', function () { renderWallpaper(r); serialize(); });
+    });
+
+    wireBox(r);
+  }
+
+  function addRoom() {
+    var el = tpl.content.cloneNode(true).querySelector('.rc-room');
+    var r = { el: el, box: { l: 0.15, t: 0.10, w: 0.70, h: 0.80 } };
+    rooms.push(r);
+    roomsEl.appendChild(el);
+    wireRoom(r);
+  }
+
+  addBtn.addEventListener('click', addRoom);
+  addRoom(); // open with one room ready
+})();
diff --git a/views/public/book.ejs b/views/public/book.ejs
index cad865b..399e807 100644
--- a/views/public/book.ejs
+++ b/views/public/book.ejs
@@ -26,6 +26,39 @@
   .signin-card .gbtn{display:inline-flex;align-items:center;gap:8px;background:#fff;color:#222;border:1px solid #ccc;padding:10px 18px;border-radius:8px;font-weight:600;text-decoration:none;font-size:14px}
   .signin-card .gbtn svg{width:18px;height:18px}
   /* Visit-kind picker removed 2026-05-06 (NPH only books consultations). */
+
+  /* ── Room capture & measure — camera-driven quote brief ─────────── */
+  .room-capture{margin:0 0 18px;padding:16px;border:1px dashed var(--border,#d4d2c8);border-radius:12px;background:var(--bg-alt,#f8f7f2)}
+  .rc-intro strong{font-size:14px}
+  .rc-intro .helper{margin:4px 0 12px;font-size:12px;color:var(--muted,#666);line-height:1.5}
+  .rc-room{border:1px solid var(--border,#d4d2c8);border-radius:10px;padding:14px;margin:0 0 12px;background:var(--card-bg,#fff)}
+  .rc-room-head{display:flex;gap:8px;align-items:center;margin:0 0 10px}
+  .rc-room-label{flex:1}
+  .rc-remove{border:none;background:transparent;font-size:16px;cursor:pointer;color:var(--muted,#999);padding:4px 8px;line-height:1}
+  .rc-remove:hover{color:#dc2626}
+  .rc-file-btn{display:block;cursor:pointer;margin:0 0 4px}
+  .rc-file-btn>span{display:inline-block;padding:10px 16px;border-radius:8px;background:var(--ink,#0a0a0a);color:#fff;font-size:13px;font-weight:600}
+  .rc-file-btn.rc-secondary>span{background:transparent;color:var(--ink,#0a0a0a);border:1px solid var(--border,#d4d2c8)}
+  .rc-stage{margin-top:12px}
+  .rc-photo-wrap{position:relative;display:inline-block;max-width:100%;touch-action:none;-webkit-user-select:none;user-select:none}
+  .rc-photo{display:block;max-width:100%;max-height:380px;border-radius:8px}
+  .rc-box{position:absolute;border:2px solid #f5c451;box-shadow:0 0 0 9999px rgba(0,0,0,0.32);box-sizing:border-box}
+  .rc-handle{position:absolute;width:22px;height:22px;margin:-11px;border-radius:50%;background:#f5c451;border:2px solid #0a0a0a;cursor:grab;touch-action:none}
+  .rc-handle[data-corner=tl]{left:0;top:0}.rc-handle[data-corner=tr]{left:100%;top:0}
+  .rc-handle[data-corner=bl]{left:0;top:100%}.rc-handle[data-corner=br]{left:100%;top:100%}
+  .rc-wallpaper-layer{position:absolute;inset:0;background-repeat:repeat;opacity:0.92;pointer-events:none}
+  .rc-hint{font-size:12px;color:var(--muted,#666);margin:8px 0}
+  .rc-dims{display:flex;gap:10px;margin:8px 0}
+  .rc-dims label{flex:1;font-size:12px}
+  .rc-estimate{font-size:12px;color:#16a34a;margin:0 0 8px;min-height:1em}
+  .rc-wallpaper{margin-top:10px;border-top:1px solid var(--border,#eee);padding-top:10px}
+  .rc-wallpaper summary{cursor:pointer;font-size:13px;font-weight:600}
+  .rc-wp-specs{display:flex;gap:8px;margin:8px 0}
+  .rc-wp-specs label{flex:1;font-size:12px}
+  .rc-wp-note{font-size:11px;color:var(--muted,#888);margin:0}
+  .rc-wp-note.is-error{color:#dc2626}
+  .rc-uploading{font-size:12px;color:var(--muted,#666);margin-top:8px}
+  .rc-uploading.is-error{color:#dc2626}
 </style>
 
 <section class="book-page">
@@ -150,6 +183,68 @@
         <fieldset class="intake-step is-active" data-step="1">
           <h2>What's the scope?</h2>
           <p class="helper">Rough is fine — the studio confirms on the visit. This helps them arrive prepared (lift, paste table, crew size).</p>
+
+          <!-- ===== Camera capture & measure (UX #6) ===== -->
+          <div class="room-capture" data-room-capture>
+            <div class="rc-intro">
+              <strong>📷 Capture &amp; measure your rooms</strong>
+              <p class="helper">Photograph each wall, drag the box to frame it, and confirm the size — your studio quotes from real footage and true dimensions instead of a guess. You can also drop in the wallpaper you're considering to see it on the wall at scale. Optional, but it gets you a faster, tighter quote.</p>
+            </div>
+            <div class="rc-rooms" data-rc-rooms></div>
+            <button type="button" class="btn btn-ghost" data-rc-add>+ Add a room</button>
+            <input type="hidden" name="room_captures" data-rc-field value="[]">
+          </div>
+
+          <template id="rc-room-tpl">
+            <div class="rc-room">
+              <div class="rc-room-head">
+                <input type="text" class="rc-room-label" placeholder="Room name — e.g. Dining room">
+                <button type="button" class="rc-remove" aria-label="Remove room">✕</button>
+              </div>
+              <label class="rc-file-btn">
+                <span class="rc-photo-cta">📷 Take / choose wall photo</span>
+                <input type="file" accept="image/*" capture="environment" class="rc-photo-input" hidden>
+              </label>
+              <div class="rc-uploading" hidden>Uploading…</div>
+              <div class="rc-stage" hidden>
+                <div class="rc-photo-wrap">
+                  <img class="rc-photo" alt="Wall photo">
+                  <div class="rc-box">
+                    <div class="rc-wallpaper-layer"></div>
+                    <span class="rc-handle" data-corner="tl"></span>
+                    <span class="rc-handle" data-corner="tr"></span>
+                    <span class="rc-handle" data-corner="bl"></span>
+                    <span class="rc-handle" data-corner="br"></span>
+                  </div>
+                </div>
+                <p class="rc-hint">Drag the yellow corners so the box frames the wall edge-to-edge, then confirm the size below.</p>
+                <div class="rc-dims">
+                  <label>Ceiling height (ft)<input type="number" class="rc-height" min="6" max="40" step="0.5" value="9"></label>
+                  <label>Wall width (ft)<input type="number" class="rc-width" min="1" max="120" step="0.5"></label>
+                </div>
+                <p class="rc-estimate"></p>
+                <label class="rc-file-btn rc-secondary">
+                  <span class="rc-video-cta">🎬 Add a room walkthrough video (optional)</span>
+                  <input type="file" accept="video/*" capture="environment" class="rc-video-input" hidden>
+                </label>
+                <details class="rc-wallpaper">
+                  <summary>🪞 Preview your own wallpaper on this wall</summary>
+                  <p class="helper">Upload the wallpaper image and give us its exact specs — we tile it on the wall at true scale.</p>
+                  <label class="rc-file-btn rc-secondary">
+                    <span class="rc-wp-cta">Upload wallpaper image</span>
+                    <input type="file" accept="image/*" class="rc-wp-input" hidden>
+                  </label>
+                  <div class="rc-wp-specs">
+                    <label>Panel width (in)<input type="number" class="rc-wp-width" min="1" max="120" step="0.25" placeholder="27"></label>
+                    <label>Vertical repeat (in)<input type="number" class="rc-wp-repeat" min="0" max="240" step="0.25" placeholder="18"></label>
+                    <label>Match<select class="rc-wp-match"><option value="straight">Straight match</option><option value="half_drop">Half-drop</option></select></label>
+                  </div>
+                  <p class="rc-wp-note">Exact panel width and vertical repeat are required — without them we can't show the paper at true scale.</p>
+                </details>
+              </div>
+            </div>
+          </template>
+
           <label>Rooms / walls being papered <input type="text" name="surfaces" placeholder="e.g. Dining room walls, primary suite, powder room"></label>
           <div class="row3">
             <label>Approx. square feet <input type="number" name="square_feet" min="0" step="10" placeholder="e.g. 320"></label>
@@ -322,6 +417,7 @@
   <script>window.NPH_STRIPE_PK = <%- JSON.stringify(stripePublishableKey) %>;</script>
 <% } %>
 <script src="/js/calendar-consumer.js" defer></script>
+<script src="/js/room-capture.js" defer></script>
 <script>
   // 5-step intake wizard navigation. The form itself still posts to the same
   // /api/installers/:slug/book endpoint that calendar-consumer.js wires up —

← 9df4b87 Add POST /api/booking-media — public upload for the room-cap  ·  back to NationalPaperHangers  ·  Persist room_captures on booking POST + derive sq ft from me 6557a78 →