← 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})`);
});