← 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
A public/js/room-capture.jsM views/public/book.ejs
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 & 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 →