← 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
A public/css/funnel.cssM server.jsM views/index.ejsA views/lead.ejs
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–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–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 & 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 & 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 →