← back to Wallpapercontractor
server.js
263 lines
try { require('dotenv').config(); } catch (_) { /* dotenv optional */ }
const express = require('express');
const path = require('path');
const fs = require('fs');
const helmet = require('helmet');
const compression = require('compression');
const morgan = require('morgan');
const app = express();
const PORT = process.env.PORT || 9930;
const GA_ID = process.env.GA_ID || 'G-LC83F70YHV';
const contractors = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'contractors.json'), 'utf8'));
// NPH ops notification (server-to-server, fire-and-forget). On Kamatera, set
// NPH_LEAD_ENDPOINT=http://127.0.0.1:9765/api/partner-lead (same box, internal).
// PARTNER_LEAD_TOKEN must match the value in NPH's .env.
const NPH_LEAD_ENDPOINT = process.env.NPH_LEAD_ENDPOINT || 'https://nationalpaperhangers.com/api/partner-lead';
const PARTNER_LEAD_TOKEN = process.env.PARTNER_LEAD_TOKEN || '';
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(helmet({
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
}));
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');
next();
});
// Never serve editor/snapshot artifacts from the static root
app.use((req, res, next) => {
if (/\.(bak|orig|swp|tmp)(\.|$)|\.pre-|~$/i.test(req.path)) {
return res.status(404).render('404', { site: SITE });
}
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();
const allCities = Array.from(new Set(contractors.map(c => c.city))).sort();
const SITE = {
domain: 'wallpapercontractor.com',
name: 'Wallpaper Contractor',
tagline: 'The bonded-crew index for commercial wallcovering installation',
description: 'A curated index of bonded, insured wallcovering installation contractors built for procurement teams, GCs, and design firms staffing 30,000-square-foot scopes — hospitality, healthcare, multifamily, retail, corporate, government. Single-installer paperhangers route separately.',
email: 'info@wallpapercontractor.com',
phone: '888-373-4564',
address: 'Designer Wallcoverings, 15442 Ventura Bl #102, Sherman Oaks CA 91403',
ga_id: GA_ID,
};
function sortContractors(list, sort) {
const copy = list.slice();
switch (sort) {
case 'company':
return copy.sort((a, b) => a.company.localeCompare(b.company));
case 'city':
return copy.sort((a, b) => a.city.localeCompare(b.city));
case 'crew_max':
return copy.sort((a, b) => b.crew_max - a.crew_max);
case 'years':
return copy.sort((a, b) => b.years_in_business - a.years_in_business);
case 'region':
return copy.sort((a, b) => (a.regions[0] || '').localeCompare(b.regions[0] || ''));
case 'newest':
default:
return copy.sort((a, b) => b.id - a.id);
}
}
app.get('/', (req, res) => {
const { q, region, segment, specialty, bonded, insured, sort } = req.query;
let filtered = contractors.slice();
if (q) {
const needle = String(q).toLowerCase();
filtered = filtered.filter(c =>
c.company.toLowerCase().includes(needle) ||
c.principal.toLowerCase().includes(needle) ||
c.city.toLowerCase().includes(needle) ||
c.specialties.some(s => s.toLowerCase().includes(needle)) ||
c.segments.some(s => s.toLowerCase().includes(needle))
);
}
if (region) filtered = filtered.filter(c => c.regions.includes(String(region)));
if (segment) filtered = filtered.filter(c => c.segments.includes(String(segment)));
if (specialty) filtered = filtered.filter(c => c.specialties.includes(String(specialty)));
if (bonded === '1') filtered = filtered.filter(c => c.bonded);
if (insured === '1') filtered = filtered.filter(c => c.insured);
filtered = sortContractors(filtered, sort);
res.render('index', {
site: SITE,
contractors: filtered,
total: contractors.length,
matched: filtered.length,
filters: {
q: q || '',
region: region || '',
segment: segment || '',
specialty: specialty || '',
bonded: bonded || '',
insured: insured || '',
sort: sort || 'newest',
},
facets: { regions: allRegions, segments: allSegments, specialties: allSpecialties, cities: allCities },
});
});
app.get('/contractor/:id', (req, res) => {
const id = Number(req.params.id);
const contractor = contractors.find(c => c.id === id);
if (!contractor) return res.status(404).render('404', { site: SITE });
const related = contractors
.filter(c => c.id !== contractor.id && c.segments.some(s => contractor.segments.includes(s)))
.slice(0, 4);
res.render('contractor', { site: SITE, contractor, related });
});
app.get('/about', (req, res) => {
res.render('about', { site: SITE });
});
app.get('/privacy', (req, res) => {
res.render('privacy', { 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.' });
}
// Notify NPH ops, server-to-server. Fire-and-forget with a hard timeout so it
// NEVER blocks the visitor's response/redirect; the local capture above is the
// durable record, this is just the ops heads-up.
if (PARTNER_LEAD_TOKEN && typeof fetch === 'function') {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 4000);
fetch(NPH_LEAD_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-partner-token': PARTNER_LEAD_TOKEN },
body: JSON.stringify({
source: lead.source, lead_id: lead.id,
name, email, phone, zip, project_type, square_footage, timeline, material_status, role, notes,
}),
signal: ac.signal,
})
.then(r => { if (!r.ok) console.warn('[lead] NPH ops notify non-200:', r.status); })
.catch(e => console.warn('[lead] NPH ops notify failed:', e && e.message))
.finally(() => clearTimeout(t));
}
// Wire into the nationalpaperhangers funnel: land the visitor on /find (the
// installer-search results — the real funnel entry), pre-filtered to their
// project segment when it maps cleanly. NPH /find matches ?segment= against
// each installer's market_segments; the extra params (zip/project/sqft) ride
// along for attribution and are ignored as filters (so results never go empty).
const NPH_SEGMENT = {
residential: 'luxury_residential',
multifamily: 'luxury_residential',
hospitality: 'hospitality',
retail: 'retail',
// corporate + healthcare have no matching NPH segment — omit so /find shows
// the full installer breadth rather than an empty filtered result.
};
const seg = NPH_SEGMENT[project_type] || '';
const params = new URLSearchParams({
utm_source: 'wallpapercontractor',
utm_medium: 'lead_funnel',
utm_campaign: 'find_installer',
utm_content: project_type,
project: project_type, sqft: square_footage, zip, lead_id: lead.id,
});
if (seg) params.set('segment', seg);
const redirect = `https://nationalpaperhangers.com/find?${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 }));
app.listen(PORT, () => {
console.log(`wallpapercontractor listening on :${PORT} (${contractors.length} contractors loaded, GA ${GA_ID})`);
});