← back to NationalPaperHangers
Surface the structured booking brief in the installer notification email
841f9be0c3d70ceec8ad30ac114d57ab1f25351d · 2026-05-18 16:25:45 -0700 · SteveStudio2
UX backlog #6's promise is 'the installer arrives prepped' — but the new
booking email showed only customer/when/type/address/notes. All the brief
data (ceiling height, surface state, access, brand/SKU, rolls, sq ft,
material, who's ordering, wallpaper-selected) was captured and stored but
never delivered where the installer first sees it. Added a 'Project brief'
section via prettyEnum + infoRow helpers (empty fields omitted; handles
both string req.body and boolean DB-row shapes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
Diff
commit 841f9be0c3d70ceec8ad30ac114d57ab1f25351d
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Mon May 18 16:25:45 2026 -0700
Surface the structured booking brief in the installer notification email
UX backlog #6's promise is 'the installer arrives prepped' — but the new
booking email showed only customer/when/type/address/notes. All the brief
data (ceiling height, surface state, access, brand/SKU, rolls, sq ft,
material, who's ordering, wallpaper-selected) was captured and stored but
never delivered where the installer first sees it. Added a 'Project brief'
section via prettyEnum + infoRow helpers (empty fields omitted; handles
both string req.body and boolean DB-row shapes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
lib/email.js | 44 +++++++++++++++++++++++++++++++++++++++++---
1 file changed, 41 insertions(+), 3 deletions(-)
diff --git a/lib/email.js b/lib/email.js
index 8a22809..971b99c 100644
--- a/lib/email.js
+++ b/lib/email.js
@@ -76,22 +76,60 @@ function bookingConfirmationCustomer({ booking, installer, publicUrl }) {
};
}
+// Enum identifier → human label: 'new_plaster' → 'New plaster'.
+function prettyEnum(v) {
+ if (v === null || v === undefined || v === '') return '';
+ return String(v).replace(/_/g, ' ').replace(/^./, c => c.toUpperCase());
+}
+
+// One label/value <tr>, omitted entirely when the value is empty.
+function infoRow(label, value) {
+ if (value === null || value === undefined || value === '') return '';
+ return `<tr><td style="padding:6px 0;color:#666;width:150px;vertical-align:top">${escapeHtml(label)}</td>` +
+ `<td style="padding:6px 0">${escapeHtml(String(value))}</td></tr>`;
+}
+
function bookingNotificationInstaller({ booking, installer, publicUrl }) {
const when = new Date(booking.scheduled_start).toLocaleString('en-US', {
weekday: 'long', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit'
});
+ // product_sourced arrives as a boolean (DB row) or string (raw req.body).
+ const ps = booking.product_sourced;
+ const productSourced = (ps === true || ps === 'true') ? 'Yes — brand & pattern chosen'
+ : (ps === false || ps === 'false') ? 'Not yet — wants guidance'
+ : '';
+ // Structured project brief (UX backlog #6) — surfaced in the email itself so
+ // the installer can scope tools, ladder height, paste type, and roll count
+ // before they ever open the dashboard. The whole point of the brief.
+ const briefRows = [
+ infoRow('Who is ordering', prettyEnum(booking.customer_role)),
+ infoRow('Wallpaper selected', productSourced),
+ infoRow('Material', prettyEnum(booking.material)),
+ infoRow('Brand', booking.brand),
+ infoRow('Pattern / SKU', booking.brand_sku),
+ infoRow('Rooms / surfaces', booking.surfaces),
+ infoRow('Square feet', booking.square_feet ? booking.square_feet + ' sq ft' : ''),
+ infoRow('Ceiling height', booking.ceiling_height_ft ? booking.ceiling_height_ft + ' ft' : ''),
+ infoRow('Estimated rolls', booking.roll_count_estimate),
+ infoRow('Surface state', prettyEnum(booking.surface_state)),
+ infoRow('Access', prettyEnum(booking.access_constraints)),
+ infoRow('Budget band', prettyEnum(booking.budget_band))
+ ].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">
<h1 style="font-size:22px;margin:0 0 16px">New booking on your calendar</h1>
<table style="width:100%;border-collapse:collapse;margin:16px 0">
- <tr><td style="padding:6px 0;color:#666;width:140px">Customer</td><td style="padding:6px 0">${escapeHtml(booking.customer_name)} · ${escapeHtml(booking.customer_email)}${booking.customer_phone ? ' · ' + escapeHtml(booking.customer_phone) : ''}</td></tr>
+ <tr><td style="padding:6px 0;color:#666;width:150px">Customer</td><td style="padding:6px 0">${escapeHtml(booking.customer_name)} · ${escapeHtml(booking.customer_email)}${booking.customer_phone ? ' · ' + escapeHtml(booking.customer_phone) : ''}</td></tr>
<tr><td style="padding:6px 0;color:#666">When</td><td style="padding:6px 0">${when}</td></tr>
<tr><td style="padding:6px 0;color:#666">Type</td><td style="padding:6px 0">${escapeHtml(booking.project_type || 'consultation')}</td></tr>
<tr><td style="padding:6px 0;color:#666">Address</td><td style="padding:6px 0">${[booking.address_line1, booking.city, booking.state, booking.zip].filter(Boolean).map(escapeHtml).join(', ') || '—'}</td></tr>
- <tr><td style="padding:6px 0;color:#666">Notes</td><td style="padding:6px 0">${escapeHtml(booking.customer_notes || '—')}</td></tr>
</table>
- <p><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>
+ ${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>` : ''}
+ <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>
</div>`
};
}
← 2e14b8b Fix /api/near-me: accept county/town/zip filters without coo
·
back to NationalPaperHangers
·
Migration 022: room_captures JSONB on bookings 81398eb →