← back to NationalPaperHangers
Surface room captures in the installer brief — admin page + email
a1dfc05577809daceee55b1b290be55cca930d92 · 2026-05-18 16:49:55 -0700 · SteveStudio2
Admin booking-detail gets a 'Room captures' section: per room the wall
photo, walkthrough-video link, confirmed dimensions, and — when the
customer supplied their own wallpaper — its specs plus a true-scale tiled
preview on a wall of the measured proportions. The installer notification
email gets a matching 'Room captures' summary with absolute photo/video
links. Installer quotes from real footage instead of a typed guess.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M lib/email.jsM views/admin/booking-detail.ejs
Diff
commit a1dfc05577809daceee55b1b290be55cca930d92
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Mon May 18 16:49:55 2026 -0700
Surface room captures in the installer brief — admin page + email
Admin booking-detail gets a 'Room captures' section: per room the wall
photo, walkthrough-video link, confirmed dimensions, and — when the
customer supplied their own wallpaper — its specs plus a true-scale tiled
preview on a wall of the measured proportions. The installer notification
email gets a matching 'Room captures' summary with absolute photo/video
links. Installer quotes from real footage instead of a typed guess.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
lib/email.js | 20 ++++++++++++++++
views/admin/booking-detail.ejs | 54 ++++++++++++++++++++++++++++++++++++++++++
2 files changed, 74 insertions(+)
diff --git a/lib/email.js b/lib/email.js
index 971b99c..c082837 100644
--- a/lib/email.js
+++ b/lib/email.js
@@ -115,6 +115,24 @@ function bookingNotificationInstaller({ booking, installer, publicUrl }) {
infoRow('Access', prettyEnum(booking.access_constraints)),
infoRow('Budget band', prettyEnum(booking.budget_band))
].join('');
+ // Camera-captured rooms (UX #6) — photo/video links + confirmed dimensions
+ // + any customer-supplied wallpaper. URLs are sanitiser-validated upstream.
+ const captures = Array.isArray(booking.room_captures) ? booking.room_captures : [];
+ const captureRows = captures.map(function (rc) {
+ const dims = (rc.wall_width_ft && rc.wall_height_ft)
+ ? rc.wall_width_ft + ' × ' + rc.wall_height_ft + ' ft' : 'not measured';
+ const links = ['<a href="' + publicUrl + rc.photo_url + '">wall photo</a>'];
+ if (rc.video_url) links.push('<a href="' + publicUrl + rc.video_url + '">walkthrough</a>');
+ const wp = rc.wallpaper
+ ? '<br><span style="color:#666">Wallpaper — ' + escapeHtml(String(rc.wallpaper.width_in))
+ + '" panel · ' + escapeHtml(String(rc.wallpaper.repeat_in)) + '" repeat · '
+ + escapeHtml(prettyEnum(rc.wallpaper.match_type)) + ' match · '
+ + '<a href="' + publicUrl + rc.wallpaper.image_url + '">image</a></span>'
+ : '';
+ return '<tr><td style="padding:6px 0;color:#666;width:150px;vertical-align:top">'
+ + escapeHtml(rc.room || 'Room') + '</td><td style="padding:6px 0">'
+ + dims + ' · ' + links.join(' · ') + wp + '</td></tr>';
+ }).join('');
return {
subject: `New booking — ${booking.customer_name} · ${when}`,
html: `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
@@ -127,6 +145,8 @@ function bookingNotificationInstaller({ booking, installer, publicUrl }) {
</table>
${briefRows ? `<h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Project brief</h2>
<table style="width:100%;border-collapse:collapse">${briefRows}</table>` : ''}
+ ${captureRows ? `<h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Room captures (${captures.length})</h2>
+ <table style="width:100%;border-collapse:collapse">${captureRows}</table>` : ''}
<h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Customer notes</h2>
<p style="margin:4px 0 0;line-height:1.55">${escapeHtml(booking.customer_notes || '—')}</p>
<p style="margin-top:24px"><a href="${publicUrl}/admin/bookings" style="display:inline-block;padding:12px 20px;background:#0e0e0e;color:#fff;text-decoration:none;border-radius:2px">Open dashboard</a></p>
diff --git a/views/admin/booking-detail.ejs b/views/admin/booking-detail.ejs
index 3e93fe1..29b833e 100644
--- a/views/admin/booking-detail.ejs
+++ b/views/admin/booking-detail.ejs
@@ -1,6 +1,17 @@
<%- include('../partials/head', { title, admin: true }) %>
<%- include('partials/admin-header') %>
+<style>
+ .rc-admin-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:18px}
+ .rc-admin-card{border:1px solid var(--border,#d4d2c8);border-radius:10px;padding:14px;background:var(--card-bg,#fff)}
+ .rc-admin-card h3{font-size:15px;margin:0 0 8px}
+ .rc-admin-photo{display:block;max-width:100%;border-radius:6px}
+ .rc-admin-wp{margin-top:10px;border-top:1px solid var(--border,#eee);padding-top:10px}
+ .rc-admin-wp-spec{font-size:13px;margin:0 0 8px}
+ .rc-admin-preview{width:100%;border-radius:6px;border:1px solid var(--border,#ddd);background-repeat:repeat}
+ .rc-admin-card .muted{color:var(--muted,#888);font-weight:400;font-size:12px}
+</style>
+
<%
function fmt(v) { return (v === null || v === undefined || v === '') ? '—' : v; }
function pretty(v) { return v ? String(v).replace(/_/g, ' ') : '—'; }
@@ -64,6 +75,49 @@
</dl>
</section>
+ <% if (booking.room_captures && booking.room_captures.length) { %>
+ <section class="admin-section" style="grid-column:1/-1">
+ <h2>Room captures <span class="muted">(<%= booking.room_captures.length %>)</span></h2>
+ <p class="muted" style="font-size:12px;margin:0 0 14px">Customer-photographed walls with confirmed dimensions — quote from these instead of the typed estimate.</p>
+ <div class="rc-admin-grid">
+ <% booking.room_captures.forEach(function (rc) {
+ var measured = rc.wall_width_ft && rc.wall_height_ft;
+ var dims = measured ? (rc.wall_width_ft + ' × ' + rc.wall_height_ft + ' ft') : 'not measured';
+ var wp = rc.wallpaper;
+ %>
+ <div class="rc-admin-card">
+ <h3><%= rc.room || 'Room' %> <span class="muted">· <%= dims %></span></h3>
+ <% if (rc.photo_url) { %>
+ <a href="<%= rc.photo_url %>" target="_blank" rel="noopener">
+ <img src="<%= rc.photo_url %>" alt="Wall photo" class="rc-admin-photo">
+ </a>
+ <% } %>
+ <% if (rc.video_url) { %>
+ <p style="margin:8px 0 0"><a href="<%= rc.video_url %>" target="_blank" rel="noopener">▶ Room walkthrough video</a></p>
+ <% } %>
+ <% if (wp) { %>
+ <div class="rc-admin-wp">
+ <p class="rc-admin-wp-spec"><strong>Customer's wallpaper</strong><br>
+ <%= wp.width_in %>″ panel · <%= wp.repeat_in %>″ repeat · <%= pretty(wp.match_type) %> match</p>
+ <% if (measured) {
+ var tw = ((wp.width_in / 12) / rc.wall_width_ft * 100).toFixed(2);
+ var th = ((wp.repeat_in / 12) / rc.wall_height_ft * 100).toFixed(2);
+ %>
+ <div class="rc-admin-preview"
+ style="aspect-ratio:<%= rc.wall_width_ft %>/<%= rc.wall_height_ft %>;background-image:url('<%= wp.image_url %>');background-size:<%= tw %>% <%= th %>%"></div>
+ <p class="muted">Shown at true scale on a <%= dims %> wall.</p>
+ <% } else { %>
+ <a href="<%= wp.image_url %>" target="_blank" rel="noopener">
+ <img src="<%= wp.image_url %>" alt="Wallpaper" class="rc-admin-photo"></a>
+ <% } %>
+ </div>
+ <% } %>
+ </div>
+ <% }); %>
+ </div>
+ </section>
+ <% } %>
+
<% if (booking.customer_notes) { %>
<section class="admin-section">
<h2>Customer notes</h2>
← 6557a78 Persist room_captures on booking POST + derive sq ft from me
·
back to NationalPaperHangers
·
e2e-room-capture: 14-assertion test for the camera quote flo 5c5c9df →