[object Object]

← back to Wallpapercontractor

add 3-step consumer lead funnel + /api/lead → nationalpaperhangers redirect

1ab8a5f28f518ccdf6fb0eadc55bea1e811cf2c7 · 2026-05-10 08:02:45 -0700 · Steve Abrams

Files touched

Diff

commit 1ab8a5f28f518ccdf6fb0eadc55bea1e811cf2c7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 10 08:02:45 2026 -0700

    add 3-step consumer lead funnel + /api/lead → nationalpaperhangers redirect
---
 public/css/funnel.css | 449 ++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js             |  82 +++++++++
 views/index.ejs       |  12 +-
 views/lead.ejs        | 296 +++++++++++++++++++++++++++++++++
 4 files changed, 838 insertions(+), 1 deletion(-)

diff --git a/public/css/funnel.css b/public/css/funnel.css
new file mode 100644
index 0000000..2cb0d8a
--- /dev/null
+++ b/public/css/funnel.css
@@ -0,0 +1,449 @@
+/* funnel.css — high-graphic 3-step lead capture for wallpapercontractor.com */
+
+@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@400;500;600;700&display=swap');
+
+:root[data-theme="light"] .funnel,
+.funnel[data-theme="light"] {
+  --f-bg: #f6f1e8;
+  --f-bg-2: #ffffff;
+  --f-bg-3: #ece4d3;
+  --f-ink: #14161a;
+  --f-mute: #6a6157;
+  --f-rule: #d4c9b3;
+  --f-gold: #b8865c;
+  --f-gold-deep: #7d3c00;
+  --f-error: #a01818;
+}
+
+.funnel {
+  /* dark default */
+  --f-bg: #0e1c26;
+  --f-bg-2: #16252f;
+  --f-bg-3: #1f303a;
+  --f-ink: #f1ece2;
+  --f-mute: #8d9aa3;
+  --f-rule: #283a45;
+  --f-gold: #c9a14b;
+  --f-gold-deep: #e2b65c;
+  --f-error: #f57b6e;
+
+  position: relative;
+  min-height: 100vh;
+  background: var(--f-bg);
+  color: var(--f-ink);
+  font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
+  padding: 28px clamp(20px, 4vw, 64px) 40px;
+  overflow: hidden;
+}
+
+/* ambient glow */
+.funnel::before {
+  content: '';
+  position: absolute;
+  top: -240px; right: -240px; width: 520px; height: 520px;
+  background: radial-gradient(closest-side, rgba(201,161,75,0.16), transparent 70%);
+  border-radius: 50%;
+  pointer-events: none;
+}
+.funnel::after {
+  content: '';
+  position: absolute;
+  bottom: -300px; left: -200px; width: 480px; height: 480px;
+  background: radial-gradient(closest-side, rgba(201,161,75,0.10), transparent 70%);
+  border-radius: 50%;
+  pointer-events: none;
+}
+
+/* ─── Logo ─────────────────────────────────────────────────────────── */
+.funnel-logo {
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+  text-decoration: none;
+  color: var(--f-ink);
+  margin-bottom: 36px;
+  position: relative;
+}
+.funnel-logo-mark {
+  width: 22px; height: 22px;
+  background:
+    linear-gradient(135deg, var(--f-gold) 0%, var(--f-gold) 50%, transparent 50%),
+    var(--f-bg-3);
+  border-radius: 2px;
+}
+.funnel-logo-name {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-weight: 500;
+  font-size: 19px;
+  letter-spacing: .02em;
+}
+
+/* ─── Shell ────────────────────────────────────────────────────────── */
+.funnel-shell {
+  max-width: 920px;
+  margin: 0 auto;
+  position: relative;
+  z-index: 1;
+}
+
+/* ─── Progress dots ────────────────────────────────────────────────── */
+.funnel-progress {
+  display: flex;
+  gap: 10px;
+  justify-content: center;
+  margin-bottom: 40px;
+}
+.funnel-progress .dot {
+  width: 36px; height: 4px;
+  background: var(--f-rule);
+  border-radius: 2px;
+  transition: background .25s ease;
+}
+.funnel-progress .dot.active {
+  background: var(--f-gold);
+}
+
+/* ─── Step ─────────────────────────────────────────────────────────── */
+.step {
+  animation: stepIn .42s cubic-bezier(.16,.84,.36,1);
+}
+@keyframes stepIn {
+  from { opacity: 0; transform: translateY(14px); }
+  to   { opacity: 1; transform: translateY(0); }
+}
+.step-eyebrow {
+  font-family: 'Inter', sans-serif;
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: .18em;
+  text-transform: uppercase;
+  color: var(--f-gold);
+  margin-bottom: 14px;
+}
+.step-h {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-weight: 400;
+  font-size: clamp(34px, 5vw, 54px);
+  line-height: 1.05;
+  letter-spacing: -.005em;
+  margin: 0 0 16px;
+}
+.step-sub {
+  font-size: 16px;
+  color: var(--f-mute);
+  max-width: 56ch;
+  margin: 0 0 36px;
+  line-height: 1.55;
+}
+.step-sub strong {
+  color: var(--f-ink);
+  font-weight: 600;
+}
+
+/* ─── Step 1 — choice grid ─────────────────────────────────────────── */
+.choice-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+  gap: 14px;
+  margin-bottom: 36px;
+}
+.choice {
+  position: relative;
+  display: flex;
+  align-items: flex-start;
+  gap: 14px;
+  padding: 18px 18px 18px 20px;
+  background: var(--f-bg-2);
+  border: 1px solid var(--f-rule);
+  border-radius: 6px;
+  cursor: pointer;
+  transition: all .15s ease;
+}
+.choice:hover {
+  border-color: var(--f-gold);
+  transform: translateY(-1px);
+  box-shadow: 0 6px 20px rgba(0,0,0,0.18);
+}
+.choice input[type="radio"] {
+  position: absolute;
+  opacity: 0;
+  pointer-events: none;
+}
+.choice:has(input:checked) {
+  border-color: var(--f-gold);
+  background: linear-gradient(135deg, var(--f-bg-2), var(--f-bg-3));
+  box-shadow: 0 0 0 1px var(--f-gold) inset, 0 8px 24px rgba(0,0,0,0.20);
+}
+.choice-body {
+  flex: 1;
+}
+.choice-title {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-weight: 500;
+  font-size: 21px;
+  line-height: 1.15;
+  margin-bottom: 4px;
+}
+.choice-sub {
+  font-size: 12.5px;
+  color: var(--f-mute);
+  line-height: 1.4;
+}
+.choice-check {
+  width: 22px; height: 22px;
+  border-radius: 50%;
+  border: 1px solid var(--f-rule);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-weight: 700;
+  font-size: 12px;
+  color: transparent;
+  flex-shrink: 0;
+  transition: all .15s ease;
+}
+.choice:has(input:checked) .choice-check {
+  background: var(--f-gold);
+  border-color: var(--f-gold);
+  color: var(--f-bg);
+}
+
+/* ─── Step 2/3 — form grid ─────────────────────────────────────────── */
+.form-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+  gap: 18px 22px;
+  margin-bottom: 28px;
+}
+.field { display: flex; flex-direction: column; gap: 7px; }
+.field-full { grid-column: 1 / -1; }
+.field-label {
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: .12em;
+  text-transform: uppercase;
+  color: var(--f-mute);
+}
+.field-opt {
+  font-weight: 400;
+  text-transform: none;
+  letter-spacing: 0;
+  color: var(--f-mute);
+  opacity: .75;
+}
+.field input,
+.field select,
+.field textarea {
+  background: var(--f-bg-2);
+  border: 1px solid var(--f-rule);
+  border-radius: 4px;
+  padding: 12px 14px;
+  font: 15px/1.4 'Inter', sans-serif;
+  color: var(--f-ink);
+  transition: border-color .15s, box-shadow .15s, background .15s;
+}
+.field input:focus,
+.field select:focus,
+.field textarea:focus {
+  outline: none;
+  border-color: var(--f-gold);
+  background: var(--f-bg-3);
+  box-shadow: 0 0 0 3px rgba(201,161,75,0.12);
+}
+.field input::placeholder,
+.field textarea::placeholder {
+  color: var(--f-mute);
+  opacity: .55;
+}
+.field select {
+  appearance: none;
+  background-image: linear-gradient(45deg, transparent 50%, var(--f-mute) 50%),
+                    linear-gradient(-45deg, transparent 50%, var(--f-mute) 50%);
+  background-position: calc(100% - 18px) 18px, calc(100% - 13px) 18px;
+  background-size: 5px 5px;
+  background-repeat: no-repeat;
+  padding-right: 40px;
+}
+.field textarea {
+  resize: vertical;
+  min-height: 80px;
+}
+
+.consent {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+  font-size: 13px;
+  color: var(--f-mute);
+  line-height: 1.5;
+  margin-bottom: 22px;
+  padding: 14px 16px;
+  background: var(--f-bg-2);
+  border: 1px solid var(--f-rule);
+  border-radius: 4px;
+}
+.consent input[type="checkbox"] {
+  margin-top: 3px;
+  accent-color: var(--f-gold);
+  flex-shrink: 0;
+  width: 16px; height: 16px;
+}
+.consent a {
+  color: var(--f-gold);
+  text-decoration: underline;
+}
+
+/* ─── Buttons ──────────────────────────────────────────────────────── */
+.step-actions {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  gap: 14px;
+  margin-top: 12px;
+}
+.step-actions > .btn-primary:only-child {
+  margin-left: auto;
+}
+.btn-primary,
+.btn-ghost {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: 14px 28px;
+  font: 600 14px/1 'Inter', sans-serif;
+  letter-spacing: .04em;
+  border-radius: 4px;
+  border: 1px solid transparent;
+  cursor: pointer;
+  text-decoration: none;
+  transition: all .15s ease;
+}
+.btn-primary {
+  background: var(--f-gold);
+  color: #14161a;
+}
+.btn-primary:not(:disabled):hover {
+  background: var(--f-gold-deep);
+  transform: translateY(-1px);
+  box-shadow: 0 8px 24px rgba(201,161,75,0.30);
+}
+.btn-primary:disabled {
+  opacity: .35;
+  cursor: not-allowed;
+}
+.btn-ghost {
+  background: transparent;
+  color: var(--f-ink);
+  border-color: var(--f-rule);
+}
+.btn-ghost:hover {
+  border-color: var(--f-gold);
+  color: var(--f-gold);
+}
+
+.btn-submit {
+  position: relative;
+  min-width: 240px;
+}
+.btn-submit .btn-spinner {
+  display: none;
+  width: 14px; height: 14px;
+  border: 2px solid rgba(20,22,26,0.28);
+  border-top-color: #14161a;
+  border-radius: 50%;
+  animation: spin 0.7s linear infinite;
+  margin-left: 8px;
+}
+.btn-submit.loading .btn-label { opacity: .55; }
+.btn-submit.loading .btn-spinner { display: inline-block; }
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.lead-error {
+  margin-top: 14px;
+  padding: 12px 16px;
+  background: rgba(245,123,110,0.08);
+  border: 1px solid rgba(245,123,110,0.30);
+  border-radius: 4px;
+  color: var(--f-error);
+  font-size: 13px;
+}
+
+/* ─── Done state ───────────────────────────────────────────────────── */
+.step-done {
+  text-align: center;
+  padding-top: 40px;
+}
+.done-mark {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 80px; height: 80px;
+  border-radius: 50%;
+  background: var(--f-gold);
+  color: var(--f-bg);
+  font-size: 38px;
+  font-weight: 700;
+  margin-bottom: 22px;
+  box-shadow: 0 0 0 8px rgba(201,161,75,0.18), 0 12px 36px rgba(0,0,0,0.3);
+  animation: doneIn .5s cubic-bezier(.16,.84,.36,1);
+}
+@keyframes doneIn {
+  0% { opacity:0; transform: scale(.4); }
+  60% { opacity:1; transform: scale(1.08); }
+  100% { transform: scale(1); }
+}
+.step-done .step-h,
+.step-done .step-sub {
+  margin-left: auto; margin-right: auto;
+}
+.step-meta {
+  font-family: ui-monospace, Menlo, monospace;
+  font-size: 13px;
+  color: var(--f-mute);
+  letter-spacing: .04em;
+  margin: 8px 0 24px;
+}
+
+/* ─── Trust strip ──────────────────────────────────────────────────── */
+.trust-strip {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 0;
+  margin-top: 60px;
+  padding: 26px 0 8px;
+  border-top: 1px solid var(--f-rule);
+}
+.trust-item {
+  text-align: center;
+  padding: 8px 12px;
+  border-right: 1px solid var(--f-rule);
+}
+.trust-item:last-child { border-right: 0; }
+.trust-num {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-weight: 500;
+  font-size: 32px;
+  line-height: 1;
+  color: var(--f-gold);
+}
+.trust-label {
+  font-size: 11px;
+  letter-spacing: .14em;
+  text-transform: uppercase;
+  color: var(--f-mute);
+  margin-top: 8px;
+}
+
+@media (max-width: 720px) {
+  .trust-strip { grid-template-columns: repeat(2, 1fr); gap: 0; }
+  .trust-item:nth-child(2) { border-right: 0; }
+  .trust-item:nth-child(1), .trust-item:nth-child(2) {
+    border-bottom: 1px solid var(--f-rule);
+    margin-bottom: 18px;
+    padding-bottom: 22px;
+  }
+  .step-actions { flex-direction: column-reverse; align-items: stretch; }
+  .step-actions .btn-primary,
+  .step-actions .btn-ghost { width: 100%; }
+}
diff --git a/server.js b/server.js
index 05f8487..52ae104 100644
--- a/server.js
+++ b/server.js
@@ -20,6 +20,8 @@ app.use(helmet({
 }));
 app.use(compression());
 app.use(morgan('tiny'));
+app.use(express.json({ limit: '32kb' }));
+app.use(express.urlencoded({ extended: false, limit: '32kb' }));
 
 app.use((req, res, next) => {
   res.set('Cache-Control', 'no-store, must-revalidate');
@@ -28,6 +30,10 @@ app.use((req, res, next) => {
 
 app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1h' }));
 
+// Ensure data dir for leads JSONL
+const LEADS_PATH = path.join(__dirname, 'data', 'leads.jsonl');
+try { fs.mkdirSync(path.dirname(LEADS_PATH), { recursive: true }); } catch(e){}
+
 const allRegions = Array.from(new Set(contractors.flatMap(c => c.regions))).sort();
 const allSegments = Array.from(new Set(contractors.flatMap(c => c.segments))).sort();
 const allSpecialties = Array.from(new Set(contractors.flatMap(c => c.specialties))).sort();
@@ -117,6 +123,82 @@ app.get('/about', (req, res) => {
   res.render('about', { site: SITE });
 });
 
+// ─── Consumer lead funnel ─────────────────────────────────────────────
+app.get('/find-installer', (req, res) => {
+  res.render('lead', { site: SITE });
+});
+
+const VALID_PROJECT_TYPES = new Set(['residential','multifamily','hospitality','retail','corporate','healthcare']);
+const VALID_TIMELINE = new Set(['asap','2-4w','1-3m','3-6m','planning']);
+const VALID_MAT = new Set(['have','picked','need_help','commercial_grade']);
+const VALID_ROLE = new Set(['homeowner','designer','architect','gc','property_manager','business_owner','other']);
+const VALID_SQFT = new Set(['<200','200-500','500-1500','1500-5000','5000+']);
+
+function clean(s, max){ return String(s||'').trim().slice(0, max||200); }
+
+const leadIpHits = new Map();
+function ipRateLimit(ip){
+  const now = Date.now();
+  const arr = (leadIpHits.get(ip) || []).filter(t => now - t < 60_000);
+  arr.push(now);
+  leadIpHits.set(ip, arr);
+  return arr.length > 5;  // >5 leads/min from same IP
+}
+
+app.post('/api/lead', (req, res) => {
+  const ip = (req.headers['x-forwarded-for']||req.ip||'').split(',')[0].trim();
+  if (ipRateLimit(ip)) return res.status(429).json({ error: 'Too many requests. Please wait a minute.' });
+
+  const b = req.body || {};
+  const name = clean(b.name, 120);
+  const email = clean(b.email, 200).toLowerCase();
+  const phone = clean(b.phone, 40);
+  const zip = clean(b.zip, 12);
+  const project_type = clean(b.project_type, 30);
+  const square_footage = clean(b.square_footage, 30);
+  const timeline = clean(b.timeline, 20);
+  const material_status = clean(b.material_status, 30);
+  const role = clean(b.role, 30);
+  const notes = clean(b.notes, 1500);
+  const consent = b.consent === true || b.consent === 'on' || b.consent === '1' || b.consent === 'true';
+
+  if (!name) return res.status(400).json({ error: 'Name is required.' });
+  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).json({ error: 'Valid email is required.' });
+  if (!/^\d{5}$/.test(zip)) return res.status(400).json({ error: 'Valid 5-digit ZIP is required.' });
+  if (!VALID_PROJECT_TYPES.has(project_type)) return res.status(400).json({ error: 'Pick a project type.' });
+  if (!VALID_SQFT.has(square_footage)) return res.status(400).json({ error: 'Pick a wall area.' });
+  if (!VALID_TIMELINE.has(timeline)) return res.status(400).json({ error: 'Pick a timeline.' });
+  if (!VALID_MAT.has(material_status)) return res.status(400).json({ error: 'Pick a wallpaper status.' });
+  if (!VALID_ROLE.has(role)) return res.status(400).json({ error: 'Pick a role.' });
+  if (!consent) return res.status(400).json({ error: 'Consent is required to be matched.' });
+
+  const lead = {
+    id: 'lead_' + Date.now().toString(36) + Math.random().toString(36).slice(2,7),
+    ts: new Date().toISOString(),
+    source: 'wallpapercontractor.com',
+    ip, ua: clean(req.headers['user-agent'], 280),
+    referrer: clean(req.headers.referer, 300),
+    name, email, phone, zip, project_type, square_footage, timeline, material_status, role, notes,
+  };
+
+  try {
+    fs.appendFileSync(LEADS_PATH, JSON.stringify(lead) + '\n', 'utf8');
+  } catch (err) {
+    console.error('lead write failed', err);
+    return res.status(500).json({ error: 'Could not save your request — please email info@wallpapercontractor.com.' });
+  }
+
+  // Build NPH redirect URL with utm + prefill (NPH can read these)
+  const params = new URLSearchParams({
+    utm_source: 'wallpapercontractor',
+    utm_medium: 'lead_funnel',
+    utm_campaign: 'find_installer',
+    zip, project: project_type, sqft: square_footage, lead_id: lead.id,
+  });
+  const redirect = `https://nationalpaperhangers.com/?${params.toString()}`;
+  res.json({ ok: true, redirect, lead_id: lead.id });
+});
+
 app.get('/healthz', (req, res) => res.json({ ok: true, port: PORT, contractors: contractors.length }));
 
 app.use((req, res) => res.status(404).render('404', { site: SITE }));
diff --git a/views/index.ejs b/views/index.ejs
index 297e06b..f32b1e8 100644
--- a/views/index.ejs
+++ b/views/index.ejs
@@ -1,9 +1,19 @@
 <%- include('partials/head', { title: site.name + ' — ' + site.tagline, description: site.description }) %>
 
 <main class="wrap main">
+
+  <!-- ─── Consumer funnel CTA strip ────────────────────────────────── -->
+  <section class="cta-strip" style="margin:0 0 28px;padding:24px 28px;background:linear-gradient(135deg,#0e1c26,#1a3142);border:1px solid #2a4453;border-radius:6px;display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap;color:#f1ece2;">
+    <div style="flex:1;min-width:280px;">
+      <div style="font-family:'Cormorant Garamond',Georgia,serif;font-weight:500;font-size:24px;line-height:1.15;margin-bottom:6px;">Need a wallpaper installer for your project?</div>
+      <div style="font-size:13px;color:#a8b6c1;line-height:1.5;">Tell us about your job — we match you with vetted, bonded, insured installers in your area in 24 hours. Free.</div>
+    </div>
+    <a href="/find-installer" style="display:inline-flex;align-items:center;gap:8px;padding:14px 26px;background:#c9a14b;color:#14161a;font-weight:600;font-size:14px;letter-spacing:.04em;border-radius:4px;text-decoration:none;white-space:nowrap;transition:all .15s ease;" onmouseover="this.style.background='#e2b65c';this.style.transform='translateY(-1px)';" onmouseout="this.style.background='#c9a14b';this.style.transform='none';">Get matched →</a>
+  </section>
+
   <section class="hero">
     <h1>Bonded, insured, commercial-scale wallpaper contractors.</h1>
-    <p class="lead">A working directory of commercial wallcovering installation contractors — companies that carry general liability insurance, surety bonding, and crews of 8&ndash;30+ for hospitality, retail, corporate, healthcare, multifamily, and government projects. <%= total %> contractors indexed. This is not a paperhanger directory; for single-installer residential paperhangers see <a href="https://nationalpaperhangers.com">NationalPaperHangers</a>.</p>
+    <p class="lead">A working directory of commercial wallcovering installation contractors — companies that carry general liability insurance, surety bonding, and crews of 8&ndash;30+ for hospitality, retail, corporate, healthcare, multifamily, and government projects. <%= total %> contractors indexed. This is not a paperhanger directory; for single-installer residential paperhangers see <a href="https://nationalpaperhangers.com">NationalPaperHangers</a> — or <a href="/find-installer">tell us about your job and we'll match you</a>.</p>
   </section>
 
   <form class="filter-bar" method="get" action="/">
diff --git a/views/lead.ejs b/views/lead.ejs
new file mode 100644
index 0000000..0bb1d69
--- /dev/null
+++ b/views/lead.ejs
@@ -0,0 +1,296 @@
+<%- include('partials/head', { title: 'Find Your Wallpaper Installer — ' + site.name, description: 'Tell us about your project. We match you with vetted, bonded, insured wallpaper installers in your area in 24 hours.' }) %>
+
+<link rel="stylesheet" href="/css/funnel.css">
+
+<main class="funnel">
+  <div class="funnel-shell" data-step="1">
+
+    <div class="funnel-progress" aria-hidden="true">
+      <span class="dot active" data-dot="1"></span>
+      <span class="dot" data-dot="2"></span>
+      <span class="dot" data-dot="3"></span>
+    </div>
+
+    <form id="leadForm" novalidate>
+
+      <!-- ───── STEP 1 — Project type ──────────────────────────────── -->
+      <section class="step step-1" data-step="1">
+        <div class="step-eyebrow">Step 1 of 3 · Project type</div>
+        <h1 class="step-h">What kind of project<br>are we sizing?</h1>
+        <p class="step-sub">Pick the closest match. We use this to route to the right specialty crew.</p>
+
+        <div class="choice-grid" role="radiogroup" aria-label="Project type">
+          <% const TYPES = [
+            { id:'residential', title:'Single home',       sub:'Owner-occupied house, condo, or apartment.', icon:'home' },
+            { id:'multifamily', title:'Multifamily',       sub:'Apartments, lofts, condo lobbies, common areas.', icon:'building' },
+            { id:'hospitality', title:'Hospitality',       sub:'Hotels, restaurants, bars, spas, resorts.', icon:'wine' },
+            { id:'retail',      title:'Retail / showroom', sub:'Stores, boutiques, salons, dealerships.', icon:'shop' },
+            { id:'corporate',   title:'Corporate',         sub:'Offices, lobbies, conference rooms.', icon:'briefcase' },
+            { id:'healthcare',  title:'Healthcare',        sub:'Hospitals, clinics, dental, senior living.', icon:'cross' },
+          ] %>
+          <% TYPES.forEach(function(t){ %>
+            <label class="choice" data-icon="<%= t.icon %>">
+              <input type="radio" name="project_type" value="<%= t.id %>" required>
+              <div class="choice-body">
+                <div class="choice-title"><%= t.title %></div>
+                <div class="choice-sub"><%= t.sub %></div>
+              </div>
+              <div class="choice-check" aria-hidden="true">✓</div>
+            </label>
+          <% }) %>
+        </div>
+
+        <div class="step-actions">
+          <button type="button" class="btn-primary" data-next="2" disabled>Continue →</button>
+        </div>
+      </section>
+
+      <!-- ───── STEP 2 — Scope + location ──────────────────────────── -->
+      <section class="step step-2" data-step="2" hidden>
+        <div class="step-eyebrow">Step 2 of 3 · Scope &amp; location</div>
+        <h1 class="step-h">Where, when,<br>and how big?</h1>
+        <p class="step-sub">Rough numbers are fine. Installers will refine on the walkthrough.</p>
+
+        <div class="form-grid">
+          <label class="field">
+            <span class="field-label">ZIP code</span>
+            <input type="text" inputmode="numeric" pattern="[0-9]{5}" maxlength="5" name="zip" required placeholder="90210">
+          </label>
+
+          <label class="field">
+            <span class="field-label">Approx. wall area</span>
+            <select name="square_footage" required>
+              <option value="">Select…</option>
+              <option value="<200">Under 200 sq ft (one accent wall)</option>
+              <option value="200-500">200–500 sq ft (one room)</option>
+              <option value="500-1500">500–1,500 sq ft (multiple rooms)</option>
+              <option value="1500-5000">1,500–5,000 sq ft (large home / suite)</option>
+              <option value="5000+">5,000+ sq ft (commercial)</option>
+            </select>
+          </label>
+
+          <label class="field">
+            <span class="field-label">Timeline</span>
+            <select name="timeline" required>
+              <option value="">Select…</option>
+              <option value="asap">As soon as possible</option>
+              <option value="2-4w">2–4 weeks</option>
+              <option value="1-3m">1–3 months</option>
+              <option value="3-6m">3–6 months</option>
+              <option value="planning">Just planning</option>
+            </select>
+          </label>
+
+          <label class="field">
+            <span class="field-label">Wallpaper status</span>
+            <select name="material_status" required>
+              <option value="">Select…</option>
+              <option value="have">I already have the wallpaper</option>
+              <option value="picked">I've picked it, not ordered</option>
+              <option value="need_help">I still need to choose</option>
+              <option value="commercial_grade">I need commercial-grade</option>
+            </select>
+          </label>
+
+          <label class="field field-full">
+            <span class="field-label">Project notes <span class="field-opt">(optional)</span></span>
+            <textarea name="notes" rows="3" placeholder="Brief description, special requirements, mural/grasscloth/silk, prep needed, etc."></textarea>
+          </label>
+        </div>
+
+        <div class="step-actions">
+          <button type="button" class="btn-ghost" data-prev="1">← Back</button>
+          <button type="button" class="btn-primary" data-next="3">Continue →</button>
+        </div>
+      </section>
+
+      <!-- ───── STEP 3 — Contact ───────────────────────────────────── -->
+      <section class="step step-3" data-step="3" hidden>
+        <div class="step-eyebrow">Step 3 of 3 · Where to send your matches</div>
+        <h1 class="step-h">Last bit. Then<br>installers come to you.</h1>
+        <p class="step-sub">We send you 2–4 vetted matches in <strong>nationalpaperhangers.com</strong>'s installer marketplace within 24 hours. No spam, no shared lists.</p>
+
+        <div class="form-grid">
+          <label class="field">
+            <span class="field-label">Your name</span>
+            <input type="text" name="name" required autocomplete="name" placeholder="Jane Doe">
+          </label>
+
+          <label class="field">
+            <span class="field-label">Email</span>
+            <input type="email" name="email" required autocomplete="email" placeholder="jane@example.com">
+          </label>
+
+          <label class="field">
+            <span class="field-label">Phone <span class="field-opt">(optional, faster matching)</span></span>
+            <input type="tel" name="phone" autocomplete="tel" placeholder="(555) 123-4567">
+          </label>
+
+          <label class="field">
+            <span class="field-label">I am a…</span>
+            <select name="role" required>
+              <option value="">Select…</option>
+              <option value="homeowner">Homeowner</option>
+              <option value="designer">Interior designer</option>
+              <option value="architect">Architect</option>
+              <option value="gc">General contractor</option>
+              <option value="property_manager">Property manager</option>
+              <option value="business_owner">Business owner</option>
+              <option value="other">Other</option>
+            </select>
+          </label>
+        </div>
+
+        <label class="consent">
+          <input type="checkbox" name="consent" required>
+          <span>I agree to be contacted by matched installers about this project. <a href="/privacy" target="_blank">Privacy</a> · I can opt out anytime.</span>
+        </label>
+
+        <div class="step-actions">
+          <button type="button" class="btn-ghost" data-prev="2">← Back</button>
+          <button type="submit" class="btn-primary btn-submit">
+            <span class="btn-label">Get my installer matches →</span>
+            <span class="btn-spinner" aria-hidden="true"></span>
+          </button>
+        </div>
+
+        <div id="leadError" class="lead-error" role="alert" hidden></div>
+      </section>
+
+      <!-- ───── SUCCESS ────────────────────────────────────────────── -->
+      <section class="step step-done" data-step="done" hidden>
+        <div class="done-mark" aria-hidden="true">✓</div>
+        <h1 class="step-h">You're matched.</h1>
+        <p class="step-sub">We're routing you to <strong>National Paper Hangers</strong> — our installer marketplace — where you can browse the vetted installers we've selected for your project.</p>
+        <p class="step-meta">Redirecting in <span id="redirCount">3</span>s…</p>
+        <p><a id="redirNow" href="https://nationalpaperhangers.com" class="btn-primary">Take me there now →</a></p>
+      </section>
+
+    </form>
+
+    <!-- Trust strip — visible all steps -->
+    <aside class="trust-strip">
+      <div class="trust-item">
+        <div class="trust-num">524</div>
+        <div class="trust-label">Vetted installers</div>
+      </div>
+      <div class="trust-item">
+        <div class="trust-num">100%</div>
+        <div class="trust-label">Bonded &amp; insured</div>
+      </div>
+      <div class="trust-item">
+        <div class="trust-num">24h</div>
+        <div class="trust-label">Match turnaround</div>
+      </div>
+      <div class="trust-item">
+        <div class="trust-num">$0</div>
+        <div class="trust-label">To get matched</div>
+      </div>
+    </aside>
+
+  </div>
+</main>
+
+<script>
+(function(){
+  var shell = document.querySelector('.funnel-shell');
+  var form  = document.getElementById('leadForm');
+  var steps = form.querySelectorAll('.step');
+  var dots  = document.querySelectorAll('.funnel-progress .dot');
+
+  function go(stepNum){
+    steps.forEach(function(s){
+      s.hidden = (s.dataset.step !== String(stepNum));
+    });
+    dots.forEach(function(d){
+      var n = Number(d.dataset.dot);
+      d.classList.toggle('active', n <= Number(stepNum));
+    });
+    shell.dataset.step = String(stepNum);
+    window.scrollTo({ top: 0, behavior: 'smooth' });
+    if (window.gtag) gtag('event','funnel_step',{step: stepNum});
+  }
+
+  // Step-1 enable continue when a project type is chosen
+  form.querySelectorAll('input[name="project_type"]').forEach(function(r){
+    r.addEventListener('change', function(){
+      var btn = form.querySelector('button[data-next="2"]');
+      btn.disabled = false;
+    });
+  });
+
+  // next/prev wiring
+  form.addEventListener('click', function(ev){
+    var t = ev.target.closest('button');
+    if (!t) return;
+    if (t.dataset.next){
+      // validate the current step's required fields before advancing
+      var current = form.querySelector('.step:not([hidden])');
+      var bad = false;
+      current.querySelectorAll('input[required], select[required], textarea[required]').forEach(function(el){
+        if (!el.checkValidity()){
+          el.reportValidity();
+          bad = true;
+        }
+      });
+      if (bad) return;
+      go(t.dataset.next);
+    } else if (t.dataset.prev){
+      go(t.dataset.prev);
+    }
+  });
+
+  // submit
+  form.addEventListener('submit', function(ev){
+    ev.preventDefault();
+    var btn = form.querySelector('.btn-submit');
+    var err = document.getElementById('leadError');
+    err.hidden = true;
+    btn.classList.add('loading');
+    btn.disabled = true;
+
+    var data = {};
+    new FormData(form).forEach(function(v,k){ data[k]=v; });
+
+    fetch('/api/lead', {
+      method:'POST',
+      headers:{'Content-Type':'application/json'},
+      body: JSON.stringify(data)
+    })
+    .then(function(r){ return r.json().then(function(j){ return {ok:r.ok, status:r.status, body:j}; }); })
+    .then(function(res){
+      if (!res.ok){
+        err.textContent = (res.body && res.body.error) || 'Something went wrong. Please try again.';
+        err.hidden = false;
+        btn.classList.remove('loading');
+        btn.disabled = false;
+        return;
+      }
+      if (window.gtag) gtag('event','generate_lead',{value:1, currency:'USD'});
+      go('done');
+      var redirUrl = res.body.redirect || 'https://nationalpaperhangers.com';
+      var a = document.getElementById('redirNow');
+      a.href = redirUrl;
+      var n = 3;
+      var counter = setInterval(function(){
+        n -= 1;
+        document.getElementById('redirCount').textContent = String(Math.max(n,0));
+        if (n <= 0){
+          clearInterval(counter);
+          window.location.href = redirUrl;
+        }
+      }, 1000);
+    })
+    .catch(function(){
+      err.textContent = 'Network error. Please try again.';
+      err.hidden = false;
+      btn.classList.remove('loading');
+      btn.disabled = false;
+    });
+  });
+
+  go(1);
+})();
+</script>
+
+<%- include('partials/foot') %>

← 94e0e82 initial scaffold + GA gtag G-LC83F70YHV  ·  back to Wallpapercontractor  ·  hamburger nav: apply canonical pattern from sister-site flee 2a24a9b →