← back to NationalPaperHangers
Live COI request flow (UX backlog #5)
66c6c0d900bd5869e0a92f1587bd774af89b8b50 · 2026-05-10 15:14:21 -0700 · SteveStudio2
Designer-driven Certificate of Insurance request, end-to-end. Replaces
the 3-day phone-tag in luxury commercial/hospitality jobs where the
designer's primary insurance requires the installer to be named as
additional-insured on a fresh COI per project.
Schema (migration 017):
- installers.insurance JSONB — carrier, policy_number, limits.{general_aggregate_usd,per_occurrence_usd}, expiry, broker_{name,email,phone}, auto_attach_to_email
- coi_requests table — installer_id, designer_{name,company,email,phone}, project_{name,address,start_date,value_usd}, additional_insured_{name,address}, notes, status (pending/acknowledged/fulfilled/declined/expired), installer_notified_at, broker_notified_at, fulfilled_{at,pdf_url}, decline_reason, source_{ip,user_agent}
Public flow:
- /installer/:slug — shared partial views/partials/coi-request.ejs renders
the "Request COI →" CTA + reveal-on-click structured form. Gated on
insurance_on_file && claimed. Included by all 6 template variants
(editorial/trade-pro/concierge/studio/heritage/bilingue) and the
legacy installer.ejs.
- POST /installer/:slug/coi-request — validates designer/insured fields,
writes coi_requests row, fans out 3 emails (installer + broker if set
+ designer confirmation) via existing lib/email Purelymail transport.
Rate-limited via claimLimiter (5/hr/IP).
- views/public/coi-request-result.ejs — confirmation page.
Admin flow:
- /admin/insurance (GET/POST) — installer enters structured insurance
metadata + broker contact. Insurance link added to admin nav.
- /admin/coi-requests (GET) — request inbox with status badges, project
details, additional-insured name, broker-notified state.
- /admin/coi-requests/:id/status (POST) — installer can mark requests
acknowledged/fulfilled/declined. fulfilled_pdf_url validated as http(s).
E2E: tests/e2e-coi-request.js — 14/0/0 standalone, full flow:
1. Public profile renders COI section
2. Reveal form + submit
3. DB row created with correct fields
4. Login as installer, request appears in admin inbox
5. Mark fulfilled, DB status flips + fulfilled_at set
6. Cleanup deletes test row
Files touched
A db/migrations/017_coi_requests.sqlM lib/email.jsM routes/admin.jsM routes/public.jsM server.jsA tests/e2e-coi-request.jsA views/admin/coi-requests.ejsA views/admin/insurance.ejsM views/admin/partials/admin-header.ejsA views/partials/coi-request.ejsA views/public/coi-request-result.ejsM views/public/installer-tpl-bilingue.ejsM views/public/installer-tpl-concierge.ejsM views/public/installer-tpl-editorial.ejsM views/public/installer-tpl-heritage.ejsM views/public/installer-tpl-studio.ejsM views/public/installer-tpl-trade-pro.ejsM views/public/installer.ejs
Diff
commit 66c6c0d900bd5869e0a92f1587bd774af89b8b50
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Sun May 10 15:14:21 2026 -0700
Live COI request flow (UX backlog #5)
Designer-driven Certificate of Insurance request, end-to-end. Replaces
the 3-day phone-tag in luxury commercial/hospitality jobs where the
designer's primary insurance requires the installer to be named as
additional-insured on a fresh COI per project.
Schema (migration 017):
- installers.insurance JSONB — carrier, policy_number, limits.{general_aggregate_usd,per_occurrence_usd}, expiry, broker_{name,email,phone}, auto_attach_to_email
- coi_requests table — installer_id, designer_{name,company,email,phone}, project_{name,address,start_date,value_usd}, additional_insured_{name,address}, notes, status (pending/acknowledged/fulfilled/declined/expired), installer_notified_at, broker_notified_at, fulfilled_{at,pdf_url}, decline_reason, source_{ip,user_agent}
Public flow:
- /installer/:slug — shared partial views/partials/coi-request.ejs renders
the "Request COI →" CTA + reveal-on-click structured form. Gated on
insurance_on_file && claimed. Included by all 6 template variants
(editorial/trade-pro/concierge/studio/heritage/bilingue) and the
legacy installer.ejs.
- POST /installer/:slug/coi-request — validates designer/insured fields,
writes coi_requests row, fans out 3 emails (installer + broker if set
+ designer confirmation) via existing lib/email Purelymail transport.
Rate-limited via claimLimiter (5/hr/IP).
- views/public/coi-request-result.ejs — confirmation page.
Admin flow:
- /admin/insurance (GET/POST) — installer enters structured insurance
metadata + broker contact. Insurance link added to admin nav.
- /admin/coi-requests (GET) — request inbox with status badges, project
details, additional-insured name, broker-notified state.
- /admin/coi-requests/:id/status (POST) — installer can mark requests
acknowledged/fulfilled/declined. fulfilled_pdf_url validated as http(s).
E2E: tests/e2e-coi-request.js — 14/0/0 standalone, full flow:
1. Public profile renders COI section
2. Reveal form + submit
3. DB row created with correct fields
4. Login as installer, request appears in admin inbox
5. Mark fulfilled, DB status flips + fulfilled_at set
6. Cleanup deletes test row
---
db/migrations/017_coi_requests.sql | 82 ++++++++++++++
lib/email.js | 126 +++++++++++++++++++++-
routes/admin.js | 110 +++++++++++++++++++
routes/public.js | 92 ++++++++++++++++
server.js | 1 +
tests/e2e-coi-request.js | 178 +++++++++++++++++++++++++++++++
views/admin/coi-requests.ejs | 79 ++++++++++++++
views/admin/insurance.ejs | 55 ++++++++++
views/admin/partials/admin-header.ejs | 1 +
views/partials/coi-request.ejs | 98 +++++++++++++++++
views/public/coi-request-result.ejs | 31 ++++++
views/public/installer-tpl-bilingue.ejs | 1 +
views/public/installer-tpl-concierge.ejs | 1 +
views/public/installer-tpl-editorial.ejs | 1 +
views/public/installer-tpl-heritage.ejs | 1 +
views/public/installer-tpl-studio.ejs | 1 +
views/public/installer-tpl-trade-pro.ejs | 1 +
views/public/installer.ejs | 3 +
18 files changed, 861 insertions(+), 1 deletion(-)
diff --git a/db/migrations/017_coi_requests.sql b/db/migrations/017_coi_requests.sql
new file mode 100644
index 0000000..0335d6e
--- /dev/null
+++ b/db/migrations/017_coi_requests.sql
@@ -0,0 +1,82 @@
+-- 017 · Live COI request flow (UX idea #5)
+--
+-- Today: designers calling installers, calling brokers, 3-day phone-tag
+-- before a $4k/roll de Gournay install. Generic directories treat insurance
+-- as a yes/no signal; luxury commercial + hospitality need an actual
+-- designer-named-additional-insured cert PER JOB.
+--
+-- v0: capture structured insurance metadata on the installer side, give
+-- designers a one-form COI request that emails the installer + their
+-- broker with the designer-named additional-insured details. PDF
+-- generation is intentionally NOT in v0 — the broker does that.
+--
+-- Reversible:
+-- ALTER TABLE installers DROP COLUMN insurance;
+-- DROP TABLE coi_requests;
+
+BEGIN;
+
+-- Structured insurance metadata. Existing boolean `insurance_on_file` +
+-- `insurance_expires DATE` stay as the lightweight summary. JSONB carries
+-- the rich data so we don't reshape the table later.
+ALTER TABLE installers
+ ADD COLUMN IF NOT EXISTS insurance JSONB;
+-- Expected shape:
+-- {
+-- "carrier": "Hartford",
+-- "policy_number": "GL-2024-12345",
+-- "limits": { "general_aggregate_usd": 2000000, "per_occurrence_usd": 1000000 },
+-- "expiry": "2026-12-31",
+-- "broker_name": "Jane Broker",
+-- "broker_email": "jane@broker.com",
+-- "broker_phone": "+1-555-0100",
+-- "auto_attach_to_email": true
+-- }
+
+CREATE TABLE IF NOT EXISTS coi_requests (
+ id SERIAL PRIMARY KEY,
+ installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
+
+ -- Designer / requester
+ designer_name TEXT NOT NULL,
+ designer_company TEXT,
+ designer_email TEXT NOT NULL,
+ designer_phone TEXT,
+
+ -- Project / job
+ project_name TEXT,
+ project_address TEXT,
+ project_start_date DATE,
+ project_value_usd NUMERIC(12,2),
+
+ -- Additional-insured party (often the design firm itself, sometimes the
+ -- end client). The broker NEEDS this exact phrasing on the cert.
+ additional_insured_name TEXT NOT NULL,
+ additional_insured_address TEXT,
+
+ notes TEXT,
+
+ -- Workflow
+ status TEXT NOT NULL DEFAULT 'pending',
+ -- pending | acknowledged | fulfilled | declined | expired
+ installer_notified_at TIMESTAMPTZ,
+ broker_notified_at TIMESTAMPTZ,
+ fulfilled_at TIMESTAMPTZ,
+ fulfilled_pdf_url TEXT,
+ decline_reason TEXT,
+
+ source_ip TEXT,
+ source_user_agent TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_coi_requests_installer ON coi_requests(installer_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_coi_requests_status ON coi_requests(status, created_at DESC);
+
+ALTER TABLE coi_requests
+ DROP CONSTRAINT IF EXISTS coi_requests_status_check,
+ ADD CONSTRAINT coi_requests_status_check
+ CHECK (status IN ('pending','acknowledged','fulfilled','declined','expired'));
+
+COMMIT;
diff --git a/lib/email.js b/lib/email.js
index fc64374..8a22809 100644
--- a/lib/email.js
+++ b/lib/email.js
@@ -98,8 +98,132 @@ function bookingNotificationInstaller({ booking, installer, publicUrl }) {
const { escapeHtml } = require('./utils');
+// ──────────────────────────────────────────────────────────────────────────
+// COI request emails (UX backlog #5)
+// ──────────────────────────────────────────────────────────────────────────
+
+function coiRow(label, value) {
+ if (!value) return '';
+ return `<tr><td style="padding:6px 0;color:#666;width:160px;vertical-align:top">${escapeHtml(label)}</td>` +
+ `<td style="padding:6px 0">${escapeHtml(String(value))}</td></tr>`;
+}
+
+function coiRequestInstaller({ request, installer, publicUrl }) {
+ const ins = installer.insurance || {};
+ const carrier = ins.carrier || 'your carrier';
+ return {
+ subject: `[COI request] ${request.designer_company || request.designer_name} → ${request.project_name || request.additional_insured_name}`,
+ html: `<div style="font-family:Georgia,serif;max-width:620px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
+ <h1 style="font-size:22px;margin:0 0 8px;letter-spacing:0.02em">New Certificate of Insurance request</h1>
+ <p style="font-size:13px;color:#666;margin:0 0 20px">A designer needs you (or ${escapeHtml(carrier)}) to issue a COI naming them as additional-insured for an upcoming installation.</p>
+
+ <h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Designer</h2>
+ <table style="width:100%;border-collapse:collapse">
+ ${coiRow('Name', request.designer_name)}
+ ${coiRow('Company', request.designer_company)}
+ ${coiRow('Email', request.designer_email)}
+ ${coiRow('Phone', request.designer_phone)}
+ </table>
+
+ <h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Project</h2>
+ <table style="width:100%;border-collapse:collapse">
+ ${coiRow('Project', request.project_name)}
+ ${coiRow('Address', request.project_address)}
+ ${coiRow('Start date', request.project_start_date)}
+ ${coiRow('Approx. value', request.project_value_usd ? '$' + Number(request.project_value_usd).toLocaleString() : '')}
+ </table>
+
+ <h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Name on the certificate</h2>
+ <table style="width:100%;border-collapse:collapse">
+ ${coiRow('Additional insured', request.additional_insured_name)}
+ ${coiRow('Their address', request.additional_insured_address)}
+ ${request.notes ? coiRow('Notes', request.notes) : ''}
+ </table>
+
+ <p style="font-size:13px;color:#666;line-height:1.6;margin-top:24px">
+ Forward this to ${escapeHtml(ins.broker_email || 'your broker')} and ask them to issue a fresh COI naming the
+ Additional Insured as listed above. Reply directly to ${escapeHtml(request.designer_email)} when sending the cert.
+ </p>
+
+ <p style="font-size:13px;color:#666;line-height:1.6">
+ Manage this request: ${escapeHtml(publicUrl)}/admin/coi-requests/${request.id}
+ </p>
+
+ <hr style="border:none;border-top:1px solid #ddd;margin:32px 0">
+ <p style="font-size:12px;color:#999">National Paper Hangers · info@nationalpaperhangers.com</p>
+ </div>`
+ };
+}
+
+function coiRequestBroker({ request, installer }) {
+ const ins = installer.insurance || {};
+ return {
+ subject: `[COI needed] ${installer.business_name} job — ${request.project_name || request.additional_insured_name}`,
+ html: `<div style="font-family:Georgia,serif;max-width:620px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
+ <h1 style="font-size:22px;margin:0 0 8px;letter-spacing:0.02em">Certificate of Insurance — please issue</h1>
+ <p style="font-size:14px;line-height:1.6;margin:0 0 20px">Hi${ins.broker_name ? ' ' + escapeHtml(ins.broker_name) : ''},</p>
+ <p style="font-size:14px;line-height:1.6;margin:0 0 20px">
+ ${escapeHtml(installer.business_name)} has been booked for a wallcovering installation and needs a
+ COI naming the project's design firm as additional insured. Details below.
+ </p>
+
+ <table style="width:100%;border-collapse:collapse">
+ ${coiRow('Insured (your client)', installer.business_name)}
+ ${ins.policy_number ? coiRow('Policy number', ins.policy_number) : ''}
+ ${coiRow('Project', request.project_name)}
+ ${coiRow('Project address', request.project_address)}
+ ${coiRow('Start date', request.project_start_date)}
+ </table>
+
+ <h2 style="font-size:14px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin:24px 0 4px">Add as additional insured</h2>
+ <table style="width:100%;border-collapse:collapse">
+ ${coiRow('Name (exact phrasing)', request.additional_insured_name)}
+ ${coiRow('Address', request.additional_insured_address)}
+ </table>
+
+ <p style="font-size:13px;color:#666;line-height:1.6;margin-top:24px">
+ Please email the cert directly to <strong>${escapeHtml(request.designer_email)}</strong>${request.designer_company ? ' (' + escapeHtml(request.designer_company) + ')' : ''}, with ${escapeHtml(installer.email)} on cc.
+ </p>
+
+ <hr style="border:none;border-top:1px solid #ddd;margin:32px 0">
+ <p style="font-size:12px;color:#999">Routed via National Paper Hangers · nationalpaperhangers.com</p>
+ </div>`
+ };
+}
+
+function coiRequestDesigner({ request, installer }) {
+ return {
+ subject: `Your COI request to ${installer.business_name} is in flight`,
+ 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;letter-spacing:0.02em">COI request received</h1>
+ <p style="font-size:15px;line-height:1.6">Hi ${escapeHtml(request.designer_name)},</p>
+ <p style="font-size:15px;line-height:1.6">
+ We've forwarded your Certificate of Insurance request to ${escapeHtml(installer.business_name)}${
+ (installer.insurance && installer.insurance.broker_email) ? ' and their broker' : ''
+ }.
+ </p>
+ <p style="font-size:15px;line-height:1.6">
+ Expect the cert to land in your inbox within 1–3 business days. The
+ additional-insured party will be:
+ </p>
+ <p style="font-size:15px;line-height:1.6;border-left:3px solid #999;padding-left:12px;margin:16px 0">
+ ${escapeHtml(request.additional_insured_name)}<br>
+ ${escapeHtml(request.additional_insured_address || '')}
+ </p>
+ <p style="font-size:13px;color:#666;line-height:1.6">
+ If you don't hear back within 3 business days, reply to this email and we'll nudge.
+ </p>
+ <hr style="border:none;border-top:1px solid #ddd;margin:32px 0">
+ <p style="font-size:12px;color:#999">National Paper Hangers · info@nationalpaperhangers.com</p>
+ </div>`
+ };
+}
+
module.exports = {
sendEmail,
bookingConfirmationCustomer,
- bookingNotificationInstaller
+ bookingNotificationInstaller,
+ coiRequestInstaller,
+ coiRequestBroker,
+ coiRequestDesigner
};
diff --git a/routes/admin.js b/routes/admin.js
index 7f98762..65f0cc3 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -213,6 +213,116 @@ router.post('/credentials/:id(\\d+)/delete', async (req, res, next) => {
} catch (err) { next(err); }
});
+// ─── Insurance metadata + COI request inbox (UX backlog #5) ──────────────
+router.get('/insurance', async (req, res, next) => {
+ try {
+ res.render('admin/insurance', {
+ title: 'Insurance · National Paper Hangers',
+ ins: req.installer.insurance || {}
+ });
+ } catch (err) { next(err); }
+});
+
+router.post('/insurance', async (req, res, next) => {
+ try {
+ const f = req.body || {};
+ const clamp = (s, max) => String(s || '').trim().slice(0, max);
+ const intish = (v, max) => {
+ const n = parseInt(String(v || '').replace(/[^0-9]/g, ''), 10);
+ return Number.isFinite(n) ? Math.min(max, Math.max(0, n)) : null;
+ };
+ const ins = {
+ carrier: clamp(f.carrier, 200) || null,
+ policy_number: clamp(f.policy_number, 200) || null,
+ limits: {
+ general_aggregate_usd: intish(f.general_aggregate_usd, 99999999),
+ per_occurrence_usd: intish(f.per_occurrence_usd, 99999999)
+ },
+ expiry: /^\d{4}-\d{2}-\d{2}$/.test(f.expiry) ? f.expiry : null,
+ broker_name: clamp(f.broker_name, 200) || null,
+ broker_email: clamp(f.broker_email, 200).toLowerCase() || null,
+ broker_phone: clamp(f.broker_phone, 40) || null,
+ auto_attach_to_email: f.auto_attach_to_email === 'on' || f.auto_attach_to_email === 'true'
+ };
+
+ if (ins.broker_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(ins.broker_email)) {
+ req.session.flash = { error: 'Broker email is not a valid address.' };
+ return res.redirect('/admin/insurance');
+ }
+
+ // Hard-strip nulled limits subobject if both are null — keeps JSONB clean
+ if (ins.limits.general_aggregate_usd === null && ins.limits.per_occurrence_usd === null) {
+ delete ins.limits;
+ }
+
+ const expiryDate = ins.expiry || null;
+ const onFile = !!(ins.carrier && ins.policy_number);
+
+ await db.query(
+ `UPDATE installers
+ SET insurance = $2,
+ insurance_on_file = $3,
+ insurance_expires = $4
+ WHERE id = $1`,
+ [req.installer.id, ins, onFile, expiryDate]
+ );
+
+ req.session.flash = { ok: 'Insurance details saved.' };
+ res.redirect('/admin/insurance');
+ } catch (err) { next(err); }
+});
+
+router.get('/coi-requests', async (req, res, next) => {
+ try {
+ const rows = await db.many(
+ `SELECT id, designer_name, designer_company, designer_email,
+ project_name, project_address, project_start_date, project_value_usd,
+ additional_insured_name, additional_insured_address, notes,
+ status, installer_notified_at, broker_notified_at, fulfilled_at,
+ fulfilled_pdf_url, decline_reason, created_at
+ FROM coi_requests
+ WHERE installer_id = $1
+ ORDER BY created_at DESC
+ LIMIT 200`,
+ [req.installer.id]
+ );
+ res.render('admin/coi-requests', {
+ title: 'COI requests · National Paper Hangers',
+ requests: rows
+ });
+ } catch (err) { next(err); }
+});
+
+router.post('/coi-requests/:id(\\d+)/status', async (req, res, next) => {
+ try {
+ const allowed = new Set(['pending','acknowledged','fulfilled','declined']);
+ const newStatus = allowed.has(req.body.status) ? req.body.status : null;
+ if (!newStatus) {
+ req.session.flash = { error: 'Invalid status.' };
+ return res.redirect('/admin/coi-requests');
+ }
+ const declineReason = newStatus === 'declined'
+ ? String(req.body.decline_reason || '').trim().slice(0, 500) || null
+ : null;
+ const fulfilledPdfUrl = newStatus === 'fulfilled' && /^https?:\/\//.test(req.body.fulfilled_pdf_url || '')
+ ? String(req.body.fulfilled_pdf_url).trim().slice(0, 500)
+ : null;
+
+ await db.query(
+ `UPDATE coi_requests
+ SET status = $2,
+ decline_reason = $3,
+ fulfilled_pdf_url = COALESCE($4, fulfilled_pdf_url),
+ fulfilled_at = CASE WHEN $2 = 'fulfilled' THEN now() ELSE fulfilled_at END,
+ updated_at = now()
+ WHERE id = $1 AND installer_id = $5`,
+ [req.params.id, newStatus, declineReason, fulfilledPdfUrl, req.installer.id]
+ );
+ req.session.flash = { ok: `Request marked ${newStatus}.` };
+ res.redirect('/admin/coi-requests');
+ } catch (err) { next(err); }
+});
+
function sanitizeWebsite(input) {
// Returns a safe http(s) URL string, or null. Strips javascript:/data:/etc.
// Length-capped to 500 to bound DB write size.
diff --git a/routes/public.js b/routes/public.js
index 1a3e73a..f61666a 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -396,6 +396,98 @@ router.post('/installer/:slug/notify-when-live', express.urlencoded({ extended:
} catch (err) { next(err); }
});
+// POST /installer/:slug/coi-request — designer-driven Certificate of Insurance
+// request (UX backlog #5). Captures structured request, emails the installer
+// + their broker (if listed), and confirms to the designer. No PDF generation
+// in v0 — the broker issues the cert and emails the designer directly.
+router.post('/installer/:slug/coi-request', express.urlencoded({ extended: false }), async (req, res, next) => {
+ try {
+ const installer = await db.one(
+ `SELECT id, slug, business_name, email, insurance, insurance_on_file, claim_status, status
+ FROM installers WHERE slug = $1 AND (status = 'active' OR claim_status = 'unclaimed')`,
+ [req.params.slug]
+ );
+ if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
+
+ const f = req.body || {};
+ const trim = (s, max) => String(s || '').trim().slice(0, max);
+ const designerName = trim(f.designer_name, 120);
+ const designerEmail = trim(f.designer_email, 200).toLowerCase();
+ const additional = trim(f.additional_insured_name, 200);
+
+ if (!designerName || !designerEmail || !additional) {
+ return res.status(400).render('public/coi-request-result', {
+ title: 'Missing fields',
+ installer, ok: false,
+ error: 'Designer name, email, and the name on the certificate are required.'
+ });
+ }
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(designerEmail)) {
+ return res.status(400).render('public/coi-request-result', {
+ title: 'Invalid email',
+ installer, ok: false,
+ error: 'Please enter a valid email address.'
+ });
+ }
+
+ const designerCompany = trim(f.designer_company, 200) || null;
+ const designerPhone = trim(f.designer_phone, 40) || null;
+ const projectName = trim(f.project_name, 200) || null;
+ const projectAddress = trim(f.project_address, 400) || null;
+ const projectStartDate = /^\d{4}-\d{2}-\d{2}$/.test(f.project_start_date) ? f.project_start_date : null;
+ const projectValueRaw = String(f.project_value_usd || '').replace(/[^0-9.]/g, '');
+ const projectValueUsd = projectValueRaw ? Math.min(99999999, Math.max(0, parseFloat(projectValueRaw))) : null;
+ const additionalAddress = trim(f.additional_insured_address, 400) || null;
+ const notes = trim(f.notes, 800) || null;
+
+ const ua = (req.headers['user-agent'] || '').slice(0, 200);
+ const ip = (req.headers['x-forwarded-for'] || req.ip || '').toString().split(',')[0].trim().slice(0, 64);
+
+ const inserted = await db.one(
+ `INSERT INTO coi_requests (
+ installer_id, designer_name, designer_company, designer_email, designer_phone,
+ project_name, project_address, project_start_date, project_value_usd,
+ additional_insured_name, additional_insured_address, notes,
+ source_ip, source_user_agent
+ ) VALUES (
+ $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14
+ ) RETURNING *`,
+ [installer.id, designerName, designerCompany, designerEmail, designerPhone,
+ projectName, projectAddress, projectStartDate, projectValueUsd,
+ additional, additionalAddress, notes, ip, ua]
+ );
+
+ // Fan-out emails — best-effort, non-blocking on failure
+ const email = require('../lib/email');
+ const publicUrl = process.env.PUBLIC_URL || `http://localhost:${process.env.PORT || 9765}`;
+ const ins = installer.insurance || {};
+
+ const sendOps = [];
+ if (installer.email) {
+ const tpl = email.coiRequestInstaller({ request: inserted, installer, publicUrl });
+ sendOps.push(email.sendEmail({ to: installer.email, ...tpl })
+ .then(r => r.ok && db.query(`UPDATE coi_requests SET installer_notified_at=now() WHERE id=$1`, [inserted.id])));
+ }
+ if (ins.broker_email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(ins.broker_email)) {
+ const tpl = email.coiRequestBroker({ request: inserted, installer });
+ sendOps.push(email.sendEmail({ to: ins.broker_email, ...tpl })
+ .then(r => r.ok && db.query(`UPDATE coi_requests SET broker_notified_at=now() WHERE id=$1`, [inserted.id])));
+ }
+ {
+ const tpl = email.coiRequestDesigner({ request: inserted, installer });
+ sendOps.push(email.sendEmail({ to: designerEmail, ...tpl }));
+ }
+ Promise.allSettled(sendOps).catch(() => {}); // fire-and-forget
+
+ res.render('public/coi-request-result', {
+ title: 'COI request received',
+ installer,
+ ok: true,
+ request: inserted
+ });
+ } catch (err) { next(err); }
+});
+
// /bookings/:uuid — booking detail page with PII (customer email, phone,
// address). Access is gated three ways:
// (a) ?t=<HMAC sig> — the link emailed in the booking confirmation
diff --git a/server.js b/server.js
index 26c625f..4108c36 100644
--- a/server.js
+++ b/server.js
@@ -180,6 +180,7 @@ app.use(['/installer/:slug/claim', '/installer/:slug/claim/complete'], claimLimi
app.use('/api/installers/:slug/book', bookLimiter);
app.use('/api/installers.geo', geoLimiter);
app.use('/installer/:slug/notify-when-live', claimLimiter);
+app.use('/installer/:slug/coi-request', claimLimiter);
app.use('/', publicRoutes);
app.use('/', authRoutes);
diff --git a/tests/e2e-coi-request.js b/tests/e2e-coi-request.js
new file mode 100644
index 0000000..078756e
--- /dev/null
+++ b/tests/e2e-coi-request.js
@@ -0,0 +1,178 @@
+// e2e click-test: Live COI request flow (UX backlog #5).
+//
+// Drives the end-to-end designer flow + admin inbox:
+// 1. Public /installer/:slug — "Request COI →" button visible (gated on
+// insurance_on_file=true).
+// 2. Reveal form, fill required fields, submit → /coi-request-result page
+// renders ok.
+// 3. DB: row exists in coi_requests for the installer, status='pending'.
+// 4. Login as the installer → /admin/coi-requests shows the request.
+// 5. Mark fulfilled → DB row status='fulfilled', fulfilled_at set.
+// 6. Cleanup — delete the test request row.
+//
+// Run: node tests/e2e-coi-request.js
+// Env: BASE=http://localhost:9765 (default)
+// SLUG=atelier-bond-nyc (default — must be a claimed installer with
+// insurance_on_file=true)
+// EMAIL=demo+bond@example.com (matching SLUG)
+// PW=demo1234
+
+const path = require('path');
+require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
+const { chromium } = require('playwright-core');
+
+const BASE = process.env.BASE || 'http://localhost:9765';
+const SLUG = process.env.SLUG || 'atelier-bond-nyc';
+const EMAIL = process.env.EMAIL || 'demo+bond@example.com';
+const PW = process.env.PW || 'demo1234';
+const IS_LOCAL = /localhost|127\.0\.0\.1/.test(BASE);
+
+if (!IS_LOCAL) {
+ console.log(`[e2e-coi-request] SKIP — BASE=${BASE} is remote; this test writes to DB and verifies via SQL`);
+ process.exit(78);
+}
+
+function findChromium() {
+ const paths = [
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
+ process.env.CHROME_PATH,
+ ].filter(Boolean);
+ for (const p of paths) { try { require('fs').accessSync(p); return p; } catch {} }
+ return null;
+}
+
+let _db = null;
+function db() {
+ if (_db) return _db;
+ _db = require('../lib/db');
+ return _db;
+}
+
+const DESIGNER_NAME = `E2E Test Designer ${Date.now()}`;
+const DESIGNER_EMAIL = `e2e-test-${Date.now()}@example.com`;
+const ADDITIONAL_INSURED = 'ACME Design LLC, its officers and directors';
+
+(async () => {
+ const exe = findChromium();
+ if (!exe) { console.error('FAIL: no Chromium binary'); process.exit(2); }
+ console.log(`[e2e-coi] using browser: ${exe}`);
+
+ const browser = await chromium.launch({ executablePath: exe, headless: true });
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+ const page = await ctx.newPage();
+ let pass = 0, fail = 0;
+ const assert = (cond, msg) => { if (cond) { console.log(` ✓ ${msg}`); pass++; } else { console.log(` ✗ ${msg}`); fail++; } };
+
+ let createdRequestId = null;
+
+ try {
+ // ─── Step 1 — Public profile renders the COI section ────────────────
+ console.log(`\n[step 1] public /installer/${SLUG} renders COI section`);
+ await page.goto(`${BASE}/installer/${SLUG}`, { waitUntil: 'domcontentloaded' });
+ const coiSection = await page.$('#coi-section');
+ assert(!!coiSection, 'COI section #coi-section present');
+ const btn = await page.$('[data-coi-toggle]');
+ assert(!!btn, '"Request COI →" button present');
+
+ // ─── Step 2 — Reveal form, fill, submit ─────────────────────────────
+ console.log(`\n[step 2] reveal form + submit request`);
+ await page.click('[data-coi-toggle]');
+ await page.waitForSelector('input[name="designer_name"]', { timeout: 3000 });
+ await page.fill('input[name="designer_name"]', DESIGNER_NAME);
+ await page.fill('input[name="designer_email"]', DESIGNER_EMAIL);
+ await page.fill('input[name="designer_company"]', 'E2E Test Firm');
+ await page.fill('input[name="project_name"]', 'E2E Test Suite');
+ await page.fill('input[name="project_address"]', '123 Test Ave, Beverly Hills CA 90210');
+ await page.fill('input[name="additional_insured_name"]', ADDITIONAL_INSURED);
+ await page.fill('input[name="additional_insured_address"]', '456 Design Blvd, LA CA 90069');
+
+ await Promise.all([
+ page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 8000 }),
+ page.click('#coi-request-form button[type="submit"]')
+ ]);
+ const url = page.url();
+ assert(/\/installer\/.*\/coi-request/.test(url) || /coi-request-result/.test(url) || url.includes(`/installer/${SLUG}/coi-request`),
+ `landed on COI result page (got ${url})`);
+
+ const body = await page.content();
+ assert(/COI request sent|in flight|received/i.test(body), 'result page shows confirmation copy');
+
+ // ─── Step 3 — DB row landed ─────────────────────────────────────────
+ console.log(`\n[step 3] DB row created`);
+ const rows = await db().many(
+ `SELECT cr.id, cr.status, cr.designer_name, cr.designer_email, cr.additional_insured_name
+ FROM coi_requests cr
+ JOIN installers i ON i.id = cr.installer_id
+ WHERE i.slug = $1 AND cr.designer_email = $2
+ ORDER BY cr.created_at DESC LIMIT 1`,
+ [SLUG, DESIGNER_EMAIL]
+ );
+ assert(rows.length === 1, `exactly 1 coi_requests row for this designer (got ${rows.length})`);
+ if (rows.length) {
+ createdRequestId = rows[0].id;
+ assert(rows[0].status === 'pending', `status=pending (got "${rows[0].status}")`);
+ assert(rows[0].designer_name === DESIGNER_NAME, `designer_name matches`);
+ assert(rows[0].additional_insured_name === ADDITIONAL_INSURED, `additional_insured_name matches`);
+ }
+
+ // ─── Step 4 — Login as installer and verify /admin/coi-requests ─────
+ console.log(`\n[step 4] login as ${EMAIL} → /admin/coi-requests shows request`);
+ const loginResp = await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
+ if (loginResp && loginResp.status() === 429) {
+ console.log(' ⊘ login rate-limit (429) — skipping admin verification phases');
+ } else {
+ await page.fill('input[name="email"]', EMAIL);
+ await page.fill('input[name="password"]', PW);
+ await Promise.all([
+ page.waitForURL(/\/admin/, { timeout: 8000 }),
+ page.click('form[action="/login"] button[type="submit"]')
+ ]);
+ assert(page.url().includes('/admin'), 'logged in');
+
+ await page.goto(`${BASE}/admin/coi-requests`, { waitUntil: 'domcontentloaded' });
+ const inboxHtml = await page.content();
+ assert(inboxHtml.includes(DESIGNER_NAME), 'admin inbox shows new request');
+ assert(/pending/i.test(inboxHtml), 'request shows as pending');
+
+ // ─── Step 5 — Mark fulfilled ──────────────────────────────────────
+ console.log(`\n[step 5] mark request fulfilled`);
+ if (createdRequestId) {
+ // Find the form for this specific request and submit "Mark fulfilled"
+ const formSel = `form[action="/admin/coi-requests/${createdRequestId}/status"] button[value="fulfilled"]`;
+ const fulfillBtn = await page.$(formSel);
+ assert(!!fulfillBtn, '"Mark fulfilled" button present');
+ if (fulfillBtn) {
+ await Promise.all([
+ page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 5000 }),
+ page.click(formSel)
+ ]);
+ const after = await db().one(
+ `SELECT status, fulfilled_at FROM coi_requests WHERE id=$1`,
+ [createdRequestId]
+ );
+ assert(after && after.status === 'fulfilled', `DB status flipped to fulfilled (got "${after && after.status}")`);
+ assert(after && after.fulfilled_at, 'fulfilled_at timestamp set');
+ }
+ }
+ }
+
+ } catch (err) {
+ console.error(`\nFAIL — uncaught: ${err.message}`);
+ fail++;
+ } finally {
+ // ─── Cleanup ─────────────────────────────────────────────────────────
+ if (createdRequestId) {
+ try {
+ await db().query(`DELETE FROM coi_requests WHERE id = $1`, [createdRequestId]);
+ console.log(`\n[cleanup] deleted coi_requests id=${createdRequestId}`);
+ } catch (e) {
+ console.error(`[cleanup] failed: ${e.message}`);
+ }
+ }
+ await browser.close();
+ }
+
+ console.log(`\n[result] ${pass} pass · ${fail} fail`);
+ process.exit(fail > 0 ? 1 : 0);
+})();
diff --git a/views/admin/coi-requests.ejs b/views/admin/coi-requests.ejs
new file mode 100644
index 0000000..f8d9365
--- /dev/null
+++ b/views/admin/coi-requests.ejs
@@ -0,0 +1,79 @@
+<%- include('../partials/head', { title, admin: true }) %>
+<%- include('partials/admin-header') %>
+<section class="admin-main">
+ <header class="admin-page-head">
+ <h1>COI requests</h1>
+ <p class="muted">Certificate of Insurance requests from designers specifying your studio for upcoming installs.</p>
+ </header>
+ <% if (flash && flash.ok) { %><div class="callout callout-success"><%= flash.ok %></div><% } %>
+ <% if (flash && flash.error) { %><div class="callout callout-warn"><%= flash.error %></div><% } %>
+
+ <p style="margin:8px 0 24px">
+ <a href="/admin/insurance" class="btn btn-ghost btn-sm">← Edit insurance details</a>
+ </p>
+
+ <% if (!requests || !requests.length) { %>
+ <div class="callout">
+ <p style="margin:0">No COI requests yet. The "Request COI" button shows up on your public profile once you fill in your <a href="/admin/insurance">insurance details</a>.</p>
+ </div>
+ <% } else { %>
+ <% requests.forEach(function(r) {
+ var statusColor = {
+ pending: '#b45309',
+ acknowledged: '#1d4ed8',
+ fulfilled: '#15803d',
+ declined: '#b91c1c',
+ expired: '#6b7280'
+ }[r.status] || '#6b7280';
+ %>
+ <article style="border:1px solid var(--border);border-radius:8px;padding:18px;margin:0 0 16px">
+ <header style="display:flex;justify-content:space-between;align-items:start;gap:12px;flex-wrap:wrap">
+ <div>
+ <div style="font-size:12px;color:#888;text-transform:uppercase;letter-spacing:0.06em"><%= new Date(r.created_at).toLocaleString('en-US') %></div>
+ <h3 style="margin:4px 0 0;font-size:18px"><%= r.designer_name %><% if (r.designer_company) { %> <span class="muted" style="font-weight:400">· <%= r.designer_company %></span><% } %></h3>
+ <p style="margin:4px 0 0;font-size:14px"><a href="mailto:<%= r.designer_email %>"><%= r.designer_email %></a><% if (r.designer_phone) { %> · <%= r.designer_phone %><% } %></p>
+ </div>
+ <span style="background:<%= statusColor %>;color:#fff;padding:4px 10px;border-radius:12px;font-size:12px;text-transform:uppercase;letter-spacing:0.06em"><%= r.status %></span>
+ </header>
+
+ <dl style="display:grid;grid-template-columns:160px 1fr;gap:6px 16px;margin:14px 0 0;font-size:14px">
+ <% if (r.project_name) { %><dt class="muted">Project</dt><dd style="margin:0"><%= r.project_name %></dd><% } %>
+ <% if (r.project_address) { %><dt class="muted">Project address</dt><dd style="margin:0"><%= r.project_address %></dd><% } %>
+ <% if (r.project_start_date) { %><dt class="muted">Start date</dt><dd style="margin:0"><%= r.project_start_date %></dd><% } %>
+ <% if (r.project_value_usd) { %><dt class="muted">Approx. value</dt><dd style="margin:0">$<%= Number(r.project_value_usd).toLocaleString() %></dd><% } %>
+ <dt class="muted">Add'l insured</dt><dd style="margin:0"><strong><%= r.additional_insured_name %></strong></dd>
+ <% if (r.additional_insured_address) { %><dt class="muted">Their address</dt><dd style="margin:0"><%= r.additional_insured_address %></dd><% } %>
+ <% if (r.notes) { %><dt class="muted">Notes</dt><dd style="margin:0;font-style:italic"><%= r.notes %></dd><% } %>
+ </dl>
+
+ <% if (r.installer_notified_at || r.broker_notified_at) { %>
+ <p class="muted" style="font-size:12px;margin:10px 0 0">
+ Routed <% if (r.installer_notified_at) { %>to you<% } %><% if (r.broker_notified_at) { %><%= r.installer_notified_at ? ' + ' : 'to ' %>broker<% } %>
+ </p>
+ <% } %>
+
+ <% if (r.status === 'pending' || r.status === 'acknowledged') { %>
+ <form method="post" action="/admin/coi-requests/<%= r.id %>/status" style="margin-top:14px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+ <% if (r.status === 'pending') { %>
+ <button type="submit" name="status" value="acknowledged" class="btn btn-ghost btn-sm">Acknowledge</button>
+ <% } %>
+ <button type="submit" name="status" value="fulfilled" class="btn btn-primary btn-sm">Mark fulfilled</button>
+ <details style="display:inline-block">
+ <summary class="btn btn-ghost btn-sm" style="display:inline-block;cursor:pointer">Decline</summary>
+ <div style="margin-top:8px;display:flex;gap:6px;align-items:center">
+ <input type="text" name="decline_reason" placeholder="reason (optional)" maxlength="500" style="min-width:200px">
+ <button type="submit" name="status" value="declined" class="btn btn-ghost btn-sm">Confirm decline</button>
+ </div>
+ </details>
+ </form>
+ <% } else if (r.status === 'fulfilled' && r.fulfilled_at) { %>
+ <p class="muted" style="font-size:12px;margin:10px 0 0">Fulfilled <%= new Date(r.fulfilled_at).toLocaleDateString('en-US') %><% if (r.fulfilled_pdf_url) { %> · <a href="<%= r.fulfilled_pdf_url %>" target="_blank" rel="noopener">cert PDF ↗</a><% } %></p>
+ <% } else if (r.status === 'declined' && r.decline_reason) { %>
+ <p class="muted" style="font-size:12px;margin:10px 0 0">Declined: <%= r.decline_reason %></p>
+ <% } %>
+ </article>
+ <% }); %>
+ <% } %>
+</section>
+<%- include('../partials/footer') %>
diff --git a/views/admin/insurance.ejs b/views/admin/insurance.ejs
new file mode 100644
index 0000000..9c6ba38
--- /dev/null
+++ b/views/admin/insurance.ejs
@@ -0,0 +1,55 @@
+<%- include('../partials/head', { title, admin: true }) %>
+<%- include('partials/admin-header') %>
+<section class="admin-main">
+ <header class="admin-page-head">
+ <h1>Insurance</h1>
+ <p class="muted">Designers in luxury commercial & hospitality often need a fresh Certificate of Insurance naming their firm as additional insured for each job. Filling this in lets us route their COI requests to you (and your broker) automatically.</p>
+ </header>
+ <% if (flash && flash.ok) { %><div class="callout callout-success"><%= flash.ok %></div><% } %>
+ <% if (flash && flash.error) { %><div class="callout callout-warn"><%= flash.error %></div><% } %>
+
+ <form method="post" action="/admin/insurance" class="profile-form" style="max-width:720px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+
+ <fieldset>
+ <legend>General liability policy</legend>
+ <label>Carrier
+ <input type="text" name="carrier" value="<%= ins.carrier || '' %>" maxlength="200" placeholder="e.g. The Hartford">
+ </label>
+ <label>Policy number
+ <input type="text" name="policy_number" value="<%= ins.policy_number || '' %>" maxlength="200">
+ </label>
+ <label>General aggregate ($)
+ <input type="text" inputmode="numeric" pattern="[0-9,$\s]*" name="general_aggregate_usd"
+ value="<%= (ins.limits && ins.limits.general_aggregate_usd) || '' %>" placeholder="2,000,000">
+ </label>
+ <label>Per-occurrence ($)
+ <input type="text" inputmode="numeric" pattern="[0-9,$\s]*" name="per_occurrence_usd"
+ value="<%= (ins.limits && ins.limits.per_occurrence_usd) || '' %>" placeholder="1,000,000">
+ </label>
+ <label>Expiry date
+ <input type="date" name="expiry" value="<%= ins.expiry || '' %>">
+ </label>
+ </fieldset>
+
+ <fieldset>
+ <legend>Your broker</legend>
+ <p class="muted" style="font-size:13px;margin:-4px 0 12px">Optional — but when set we'll CC your broker on incoming COI requests so they can issue the cert without waiting on you to forward.</p>
+ <label>Broker name
+ <input type="text" name="broker_name" value="<%= ins.broker_name || '' %>" maxlength="200">
+ </label>
+ <label>Broker email
+ <input type="email" name="broker_email" value="<%= ins.broker_email || '' %>" maxlength="200">
+ </label>
+ <label>Broker phone
+ <input type="tel" name="broker_phone" value="<%= ins.broker_phone || '' %>" maxlength="40">
+ </label>
+ </fieldset>
+
+ <div style="display:flex;gap:10px;margin-top:8px">
+ <button type="submit" class="btn btn-primary">Save insurance details</button>
+ <a href="/admin/coi-requests" class="btn btn-ghost btn-sm">View incoming COI requests →</a>
+ </div>
+ </form>
+</section>
+<%- include('../partials/footer') %>
diff --git a/views/admin/partials/admin-header.ejs b/views/admin/partials/admin-header.ejs
index 84875de..cad8d17 100644
--- a/views/admin/partials/admin-header.ejs
+++ b/views/admin/partials/admin-header.ejs
@@ -12,6 +12,7 @@
<a href="/admin/profile" class="<%= path.startsWith('/admin/profile') ? 'is-current' : '' %>">Profile</a>
<a href="/admin/template" class="<%= path.startsWith('/admin/template') ? 'is-current' : '' %>">Page design</a>
<a href="/admin/billing" class="<%= path.startsWith('/admin/billing') ? 'is-current' : '' %>">Billing</a>
+ <a href="/admin/insurance" class="<%= path.startsWith('/admin/insurance') || path.startsWith('/admin/coi-requests') ? 'is-current' : '' %>">Insurance</a>
</nav>
<div class="header-actions">
<button type="button" class="theme-toggle" aria-label="Toggle theme" data-theme-toggle>
diff --git a/views/partials/coi-request.ejs b/views/partials/coi-request.ejs
new file mode 100644
index 0000000..d715ea9
--- /dev/null
+++ b/views/partials/coi-request.ejs
@@ -0,0 +1,98 @@
+<%# Shared "Request COI" section used by all installer-template variants.
+ Renders nothing for unclaimed studios or studios without insurance_on_file.
+ Expects: installer, csrfToken in scope. %>
+<% if (installer.insurance_on_file && installer.claim_status !== 'unclaimed') { %>
+ <section id="coi-section" class="profile-section coi-section" style="max-width:680px;margin:32px auto;padding:24px;border:1px solid var(--border,#ddd);border-radius:8px">
+ <h2 style="margin:0 0 4px;font-size:22px;letter-spacing:0.01em">Certificate of Insurance — on request</h2>
+ <p class="muted" style="margin:0 0 16px;font-size:14px;line-height:1.55">
+ For luxury commercial & hospitality jobs that need <em>your firm</em> named as additional insured per project. Skip the 3-day phone-tag — fill the form and the cert lands in your inbox in 1–3 business days.
+ </p>
+ <button type="button" class="btn btn-primary btn-sm" data-coi-toggle>Request COI →</button>
+ <p class="muted" style="font-size:12px;margin:12px 0 0">
+ Routed directly to <%= installer.business_name %><% if (installer.insurance && installer.insurance.broker_email) { %> + their broker<% } %>. No NPH escrow, no fees.
+ </p>
+
+ <form id="coi-request-form" method="POST" action="/installer/<%= installer.slug %>/coi-request"
+ style="display:none;margin-top:20px;display:grid;gap:14px">
+ <input type="hidden" name="_csrf" value="<%= csrfToken %>">
+
+ <fieldset style="border:none;padding:0;margin:0;display:grid;gap:10px">
+ <legend style="font-size:12px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin-bottom:6px;padding:0">You (the designer)</legend>
+ <label>Name <span style="color:#dc2626">*</span><br>
+ <input type="text" name="designer_name" required maxlength="120" style="width:100%">
+ </label>
+ <label>Design firm
+ <input type="text" name="designer_company" maxlength="200" style="width:100%">
+ </label>
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
+ <label>Email <span style="color:#dc2626">*</span><br>
+ <input type="email" name="designer_email" required maxlength="200" style="width:100%">
+ </label>
+ <label>Phone
+ <input type="tel" name="designer_phone" maxlength="40" style="width:100%">
+ </label>
+ </div>
+ </fieldset>
+
+ <fieldset style="border:none;padding:0;margin:0;display:grid;gap:10px">
+ <legend style="font-size:12px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin-bottom:6px;padding:0">The project</legend>
+ <label>Project name
+ <input type="text" name="project_name" maxlength="200" style="width:100%" placeholder="e.g. Riviera Suite — Beverly Hills">
+ </label>
+ <label>Project address
+ <input type="text" name="project_address" maxlength="400" style="width:100%">
+ </label>
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
+ <label>Install start date
+ <input type="date" name="project_start_date" style="width:100%">
+ </label>
+ <label>Approx. install value
+ <input type="text" name="project_value_usd" maxlength="14" style="width:100%" placeholder="$"
+ pattern="[0-9.,$\s]*" inputmode="decimal">
+ </label>
+ </div>
+ </fieldset>
+
+ <fieldset style="border:none;padding:0;margin:0;display:grid;gap:10px">
+ <legend style="font-size:12px;text-transform:uppercase;letter-spacing:0.08em;color:#999;margin-bottom:6px;padding:0">Name on the certificate</legend>
+ <label>Additional insured (exact phrasing) <span style="color:#dc2626">*</span><br>
+ <input type="text" name="additional_insured_name" required maxlength="200" style="width:100%" placeholder="e.g. ACME Design LLC, its officers and directors">
+ </label>
+ <label>Their address
+ <input type="text" name="additional_insured_address" maxlength="400" style="width:100%">
+ </label>
+ <label>Notes for the broker
+ <textarea name="notes" maxlength="800" rows="3" style="width:100%" placeholder="Anything special — waiver of subrogation, primary/non-contributory wording, etc."></textarea>
+ </label>
+ </fieldset>
+
+ <div style="display:flex;gap:10px;align-items:center;margin-top:6px">
+ <button type="submit" class="btn btn-primary">Send request →</button>
+ <button type="button" class="btn btn-ghost btn-sm" data-coi-cancel>Cancel</button>
+ </div>
+ </form>
+
+ <script>
+ (function(){
+ var sec = document.getElementById('coi-section');
+ if (!sec) return;
+ var t = sec.querySelector('[data-coi-toggle]');
+ var c = sec.querySelector('[data-coi-cancel]');
+ var f = document.getElementById('coi-request-form');
+ if (!t || !f) return;
+ t.addEventListener('click', function(){
+ f.style.display = 'grid';
+ t.style.display = 'none';
+ f.scrollIntoView({behavior:'smooth', block:'start'});
+ var first = f.querySelector('input[name="designer_name"]');
+ if (first) first.focus();
+ });
+ if (c) c.addEventListener('click', function(){
+ f.style.display = 'none';
+ t.style.display = '';
+ t.scrollIntoView({behavior:'smooth'});
+ });
+ })();
+ </script>
+ </section>
+<% } %>
diff --git a/views/public/coi-request-result.ejs b/views/public/coi-request-result.ejs
new file mode 100644
index 0000000..0e71701
--- /dev/null
+++ b/views/public/coi-request-result.ejs
@@ -0,0 +1,31 @@
+<%- include('../partials/head', { title }) %>
+<%- include('../partials/header') %>
+
+<section class="book-page" style="max-width:680px;margin:0 auto;padding:48px 24px">
+ <% if (ok) { %>
+ <div class="callout" style="text-align:center">
+ <h1 class="display-sm" style="margin:0 0 12px">COI request sent</h1>
+ <p style="font-size:16px;line-height:1.55">
+ We've forwarded your Certificate of Insurance request to
+ <strong><%= installer.business_name %></strong><% if (installer.insurance && installer.insurance.broker_email) { %>
+ and their broker<% } %>.
+ </p>
+ <p style="font-size:15px;line-height:1.55;margin-top:16px">
+ The cert will be issued naming
+ <strong><%= request.additional_insured_name %></strong>
+ as additional insured and emailed directly to
+ <strong><%= request.designer_email %></strong>.
+ </p>
+ <p class="muted" style="margin-top:20px;font-size:13px">Typical turnaround is 1–3 business days. If you don't hear back within 3 days, reply to your confirmation email and we'll nudge.</p>
+ <a href="/installer/<%= installer.slug %>" class="btn btn-ghost btn-sm" style="margin-top:24px">← Back to <%= installer.business_name %>'s profile</a>
+ </div>
+ <% } else { %>
+ <div class="callout" style="border-color:#dc2626">
+ <h1 class="display-sm" style="margin:0 0 12px">Something needs fixing</h1>
+ <p style="font-size:15px;line-height:1.55"><%= error %></p>
+ <a href="/installer/<%= installer.slug %>" class="btn btn-primary" style="margin-top:16px">← Back to profile</a>
+ </div>
+ <% } %>
+</section>
+
+<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-bilingue.ejs b/views/public/installer-tpl-bilingue.ejs
index 5c0de6a..b6479f8 100644
--- a/views/public/installer-tpl-bilingue.ejs
+++ b/views/public/installer-tpl-bilingue.ejs
@@ -111,4 +111,5 @@
}
})();
</script>
+<%- include('../partials/coi-request') %>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-concierge.ejs b/views/public/installer-tpl-concierge.ejs
index cd4810d..2684751 100644
--- a/views/public/installer-tpl-concierge.ejs
+++ b/views/public/installer-tpl-concierge.ejs
@@ -63,4 +63,5 @@
<%- include('../partials/social-videos') %>
</article>
+<%- include('../partials/coi-request') %>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-editorial.ejs b/views/public/installer-tpl-editorial.ejs
index 55f1e50..aca8c77 100644
--- a/views/public/installer-tpl-editorial.ejs
+++ b/views/public/installer-tpl-editorial.ejs
@@ -85,4 +85,5 @@
</section>
<% } %>
</article>
+<%- include('../partials/coi-request') %>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-heritage.ejs b/views/public/installer-tpl-heritage.ejs
index d2475e4..2e7fccb 100644
--- a/views/public/installer-tpl-heritage.ejs
+++ b/views/public/installer-tpl-heritage.ejs
@@ -91,4 +91,5 @@
<%- include('../partials/social-videos') %>
</article>
+<%- include('../partials/coi-request') %>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-studio.ejs b/views/public/installer-tpl-studio.ejs
index ca0fd26..90acf5f 100644
--- a/views/public/installer-tpl-studio.ejs
+++ b/views/public/installer-tpl-studio.ejs
@@ -93,4 +93,5 @@
<%- include('../partials/social-videos') %>
</article>
+<%- include('../partials/coi-request') %>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-trade-pro.ejs b/views/public/installer-tpl-trade-pro.ejs
index e07b98a..c991190 100644
--- a/views/public/installer-tpl-trade-pro.ejs
+++ b/views/public/installer-tpl-trade-pro.ejs
@@ -95,4 +95,5 @@
<%- include('../partials/social-videos') %>
</article>
+<%- include('../partials/coi-request') %>
<%- include('../partials/footer') %>
diff --git a/views/public/installer.ejs b/views/public/installer.ejs
index 571e3ff..4673e4c 100644
--- a/views/public/installer.ejs
+++ b/views/public/installer.ejs
@@ -170,9 +170,12 @@
</p>
<p class="muted" style="font-size:12px;margin:6px 0 0">A studio booked solid enough to be selective.</p>
<% } %>
+
</aside>
</div>
+ <%- include('../partials/coi-request') %>
+
<% if (portfolio.length > 0) { %>
<section class="profile-portfolio">
<h2 class="section-title">Selected projects</h2>
← c8e0e1a e2e-stripe-flow: real installer-session fixture + Connect UR
·
back to NationalPaperHangers
·
UX_CREATIVE_BACKLOG: mark #5 (Live COI) shipped v0 b681b43 →