[object Object]

← back to Ventura Claw

/services lead capture: modal form + /api/services-lead JSONL + best-effort George email + admin viewer endpoint

49f18911cb0aaf4695ddd5eeca09851594bafad7 · 2026-05-06 14:36:00 -0700 · Steve

Files touched

Diff

commit 49f18911cb0aaf4695ddd5eeca09851594bafad7
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 6 14:36:00 2026 -0700

    /services lead capture: modal form + /api/services-lead JSONL + best-effort George email + admin viewer endpoint
---
 server/data/services-leads.jsonl |   1 +
 server/public/services.html      | 199 +++++++++++++++++++++++++++++++++++++++
 server/server.js                 |  83 ++++++++++++++++
 3 files changed, 283 insertions(+)

diff --git a/server/data/services-leads.jsonl b/server/data/services-leads.jsonl
new file mode 100644
index 0000000..a759959
--- /dev/null
+++ b/server/data/services-leads.jsonl
@@ -0,0 +1 @@
+{"at":"2026-05-06T21:35:32.516Z","id":"lead_mouktc4k_s8ggj8","service":"LLC formation","email":"test@example.com","phone":null,"state":"CA","notes":"this is a test","ip":"127.0.0.1","ua":"curl/8.7.1"}
diff --git a/server/public/services.html b/server/public/services.html
index 9ea3c63..53b1166 100644
--- a/server/public/services.html
+++ b/server/public/services.html
@@ -270,6 +270,94 @@
   footer a { color: var(--ink-mute); }
   footer a:hover { color: var(--gold); }
 
+  /* GET STARTED button on cards */
+  .svc-cta {
+    margin-top: 14px; padding: 10px 14px;
+    background: var(--bg-3); color: var(--ink);
+    border: 1px solid var(--gold); border-radius: 3px;
+    font-family: var(--mono); font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 600;
+    cursor: pointer; align-self: flex-start;
+    transition: all 200ms;
+  }
+  .svc-cta:hover { background: var(--gold); color: var(--bg); }
+
+  /* Lead-capture modal */
+  .lead-modal {
+    position: fixed; inset: 0; background: rgba(0,0,0,0.78);
+    z-index: 1000; padding: 30px;
+    display: none; align-items: center; justify-content: center;
+    backdrop-filter: blur(6px);
+  }
+  .lead-modal.show { display: flex; }
+  .lead-card {
+    background: var(--bg-2); border: 1px solid var(--gold); border-left: 3px solid var(--gold);
+    max-width: 520px; width: 100%;
+    padding: 28px 32px;
+    max-height: 90vh; overflow-y: auto;
+    position: relative;
+  }
+  .lead-card .close-x {
+    position: absolute; top: 12px; right: 14px;
+    background: none; border: 1px solid var(--rule); color: var(--ink-mute);
+    width: 28px; height: 28px; border-radius: 4px;
+    cursor: pointer; font-size: 14px; line-height: 1;
+  }
+  .lead-card .close-x:hover { color: var(--bad); border-color: var(--bad); }
+  .lead-card h3 {
+    font-family: var(--serif); font-weight: 500; font-size: 26px; letter-spacing: -0.005em;
+    margin: 0 0 6px; color: var(--ink);
+  }
+  .lead-card .svc-tag {
+    font-family: var(--mono); font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--gold);
+    margin-bottom: 18px; display: block;
+  }
+  .lead-card label {
+    display: block; font-family: var(--mono); font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase;
+    color: var(--ink-mute); margin: 12px 0 4px;
+  }
+  .lead-card label.required::after { content: " *"; color: var(--bad); }
+  .lead-card input, .lead-card textarea {
+    width: 100%; background: var(--bg); color: var(--ink);
+    border: 1px solid var(--rule); border-radius: 3px;
+    padding: 10px 12px; font-family: var(--sans); font-size: 14px; font-weight: 300;
+    outline: none; transition: border-color 200ms;
+  }
+  .lead-card input:focus, .lead-card textarea:focus { border-color: var(--gold); box-shadow: 0 0 0 2px var(--gold-soft); }
+  .lead-card textarea { resize: vertical; min-height: 70px; max-height: 200px; }
+  .lead-card .nla-line {
+    margin: 14px 0 0; padding: 10px 12px;
+    background: rgba(198,108,77,0.08); border: 1px solid var(--bad);
+    font-size: 11px; color: var(--ink-soft); line-height: 1.5;
+  }
+  .lead-card .nla-line strong { color: var(--bad); }
+  .lead-card .submit-row {
+    display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-top: 18px;
+  }
+  .lead-card .submit-btn {
+    background: var(--gold); color: var(--bg); border: 0;
+    padding: 12px 22px; font-family: var(--mono); font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 600;
+    cursor: pointer; border-radius: 3px;
+    transition: background 200ms;
+  }
+  .lead-card .submit-btn:hover { background: var(--gold-glow); }
+  .lead-card .submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+  .lead-card .price-line {
+    font-family: var(--mono); font-size: 11px; color: var(--ink-soft);
+  }
+  .lead-card .price-line b { color: var(--gold); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 14px; }
+  .lead-card .ack {
+    margin-top: 14px; padding: 14px 16px;
+    background: rgba(110,192,129,0.08); border: 1px solid var(--good);
+    font-size: 13px; color: var(--good); display: none;
+  }
+  .lead-card .ack.show { display: block; }
+  .lead-card .err {
+    margin-top: 14px; padding: 10px 14px;
+    background: rgba(198,108,77,0.08); border: 1px solid var(--bad);
+    font-size: 12px; color: var(--bad); display: none;
+  }
+  .lead-card .err.show { display: block; }
+
   /* Mobile — chat collapses to bottom drawer */
   @media (max-width: 820px) {
     .chat-panel { width: 100%; height: 50vh; top: auto; }
@@ -506,6 +594,38 @@
   </div>
 </section>
 
+<!-- LEAD CAPTURE MODAL -->
+<div class="lead-modal" id="leadModal" role="dialog" aria-modal="true" aria-labelledby="leadModalTitle">
+  <div class="lead-card">
+    <button type="button" class="close-x" id="leadClose" aria-label="Close">×</button>
+    <span class="svc-tag" id="leadSvcTag">Service</span>
+    <h3 id="leadModalTitle">Get started</h3>
+    <p style="font-size: 13px; color: var(--ink-soft); margin: 0 0 4px;">
+      Tell us a little, we'll reply within one business day with a clear next step. No payment now — we walk you through what's free, and only quote a fee if there's something we can do for you.
+    </p>
+    <form id="leadForm">
+      <input type="hidden" id="leadService" name="service" value="">
+      <label class="required" for="leadEmail">Your email</label>
+      <input type="email" id="leadEmail" name="email" required placeholder="you@example.com" autocomplete="email">
+      <label for="leadPhone">Phone (optional)</label>
+      <input type="tel" id="leadPhone" name="phone" placeholder="(optional)" autocomplete="tel">
+      <label for="leadState">Your state (optional, helps us answer faster)</label>
+      <input type="text" id="leadState" name="state" placeholder="e.g. CA" autocomplete="address-level1" maxlength="60">
+      <label for="leadNotes">What are you trying to do?</label>
+      <textarea id="leadNotes" name="notes" rows="3" placeholder="One or two sentences — what you need, what you've already done, anything we should know."></textarea>
+      <div class="nla-line">
+        <strong>Reminder:</strong> VenturaClaw is not a law firm and not your attorney. We are a self-help document preparation service. Submitting this form does not create an attorney–client relationship. <a href="/legal-notice" target="_blank" rel="noopener" style="color: var(--gold)">Read the full notice →</a>
+      </div>
+      <div class="submit-row">
+        <span class="price-line" id="leadPrice"></span>
+        <button type="submit" class="submit-btn" id="leadSubmit">Send →</button>
+      </div>
+      <div class="ack" id="leadAck"></div>
+      <div class="err" id="leadErr"></div>
+    </form>
+  </div>
+</div>
+
 <footer>
   <span>© 2026 VenturaClaw · Steve Abrams · <a href="mailto:info@venturaclaw.com">info@venturaclaw.com</a></span>
   <span>
@@ -525,6 +645,85 @@
     el.appendChild(b);
   });
 
+  // ----- "GET STARTED →" button on every service card (opens modal) -----
+  document.querySelectorAll('.svc').forEach(el => {
+    const btn = document.createElement('button');
+    btn.type = 'button';
+    btn.className = 'svc-cta';
+    btn.textContent = 'Get Started →';
+    btn.addEventListener('click', e => {
+      e.stopPropagation();              // don't trigger the card-click chat seed
+      openLeadModal(el.dataset.svc, el);
+    });
+    el.appendChild(btn);
+  });
+
+  // ----- Lead modal wiring -----
+  const leadModal = document.getElementById('leadModal');
+  const leadClose = document.getElementById('leadClose');
+  const leadForm  = document.getElementById('leadForm');
+  const leadAck   = document.getElementById('leadAck');
+  const leadErr   = document.getElementById('leadErr');
+  const leadSubmit= document.getElementById('leadSubmit');
+
+  function openLeadModal(svcName, cardEl) {
+    document.getElementById('leadSvcTag').textContent = svcName;
+    document.getElementById('leadService').value = svcName;
+    document.getElementById('leadModalTitle').textContent = 'Get started: ' + svcName;
+    // Pull the price line from the card if available
+    const priceTags = cardEl ? cardEl.querySelectorAll('.price-tag') : [];
+    let priceText = '';
+    priceTags.forEach(p => {
+      const lbl = p.querySelector('.lbl')?.textContent || '';
+      const val = p.querySelector('.val')?.textContent || '';
+      if (lbl && val) priceText += `<b>${lbl}:</b> ${val}  ·  `;
+    });
+    document.getElementById('leadPrice').innerHTML = priceText.replace(/\s*·\s*$/, '');
+    leadAck.classList.remove('show'); leadAck.textContent = '';
+    leadErr.classList.remove('show'); leadErr.textContent = '';
+    leadSubmit.disabled = false;
+    leadSubmit.textContent = 'Send →';
+    leadModal.classList.add('show');
+    setTimeout(() => document.getElementById('leadEmail').focus(), 100);
+  }
+  function closeLeadModal() { leadModal.classList.remove('show'); }
+  leadClose.addEventListener('click', closeLeadModal);
+  leadModal.addEventListener('click', e => { if (e.target === leadModal) closeLeadModal(); });
+  document.addEventListener('keydown', e => { if (e.key === 'Escape' && leadModal.classList.contains('show')) closeLeadModal(); });
+
+  leadForm.addEventListener('submit', async e => {
+    e.preventDefault();
+    leadAck.classList.remove('show'); leadErr.classList.remove('show');
+    leadSubmit.disabled = true;
+    leadSubmit.textContent = 'Sending…';
+    const payload = {
+      service: document.getElementById('leadService').value,
+      email:   document.getElementById('leadEmail').value.trim(),
+      phone:   document.getElementById('leadPhone').value.trim(),
+      state:   document.getElementById('leadState').value.trim(),
+      notes:   document.getElementById('leadNotes').value.trim(),
+    };
+    try {
+      const r = await fetch('/api/services-lead', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify(payload)
+      });
+      const j = await r.json();
+      if (!r.ok) throw new Error(j.msg || j.error || 'Submission failed.');
+      leadAck.textContent = j.msg || 'Got it — we\'ll be in touch.';
+      leadAck.classList.add('show');
+      leadForm.reset();
+      leadSubmit.textContent = 'Sent ✓';
+      setTimeout(closeLeadModal, 3500);
+    } catch (err) {
+      leadErr.textContent = err.message || 'Network error.';
+      leadErr.classList.add('show');
+      leadSubmit.disabled = false;
+      leadSubmit.textContent = 'Try again →';
+    }
+  });
+
   // ----- chat panel state -----
   const panel  = document.getElementById('chatPanel');
   const toggle = document.getElementById('chatToggle');
diff --git a/server/server.js b/server/server.js
index a34c276..9957017 100644
--- a/server/server.js
+++ b/server/server.js
@@ -653,6 +653,89 @@ app.post("/api/demo-classify", demoLimiter, express.json({ limit: "2kb" }), asyn
     res.status(500).json({ error: "classify_failed" });
   }
 });
+// Public services-lead endpoint — captures "Get started" form submissions.
+// Writes to JSONL (lossless) + best-effort email via George Gmail (non-blocking).
+const SERVICES_LEADS_PATH = path.join(__dirname, "data", "services-leads.jsonl");
+const servicesLeadLimiter = rateLimit({
+  windowMs: 60 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false,
+  message: { error: "rate_limited", msg: "Too many submissions — try again in an hour." }
+});
+async function _emailLeadToSteve(lead) {
+  const url = process.env.GEORGE_URL || "http://100.107.67.67:9850";
+  const auth = process.env.GEORGE_AUTH || "admin:DWSecure2024!";
+  const body = {
+    to: "steve@designerwallcoverings.com",
+    subject: `[VenturaClaw lead] ${lead.service} · ${lead.email}`,
+    html: `<h2>New /services lead</h2>
+<table cellpadding=8 style="border-collapse:collapse;font-family:sans-serif">
+<tr><td><b>Service</b></td><td>${lead.service}</td></tr>
+<tr><td><b>Email</b></td><td>${lead.email}</td></tr>
+<tr><td><b>Phone</b></td><td>${lead.phone || "(none)"}</td></tr>
+<tr><td><b>State</b></td><td>${lead.state || "(none)"}</td></tr>
+<tr><td><b>Notes</b></td><td><pre style="white-space:pre-wrap;font-family:inherit;margin:0">${(lead.notes || "(none)").replace(/[<>&]/g, c => ({"<":"&lt;",">":"&gt;","&":"&amp;"}[c]))}</pre></td></tr>
+<tr><td><b>UA</b></td><td style="font-size:11px;color:#888">${lead.ua || ""}</td></tr>
+<tr><td><b>IP</b></td><td>${lead.ip || ""}</td></tr>
+<tr><td><b>Time</b></td><td>${lead.at}</td></tr>
+</table>`
+  };
+  await fetch(`${url}/api/send`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json", "Authorization": "Basic " + Buffer.from(auth).toString("base64") },
+    body: JSON.stringify(body),
+    signal: AbortSignal.timeout(5000),
+  });
+}
+app.post("/api/services-lead", servicesLeadLimiter, express.json({ limit: "4kb" }), async (req, res) => {
+  const { service, email, phone, state, notes } = req.body || {};
+  // Validate
+  if (typeof service !== "string" || service.length < 2 || service.length > 80)
+    return res.status(400).json({ error: "bad_service" });
+  if (typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 200)
+    return res.status(400).json({ error: "bad_email" });
+  if (notes && (typeof notes !== "string" || notes.length > 1500))
+    return res.status(400).json({ error: "bad_notes" });
+  if (phone && (typeof phone !== "string" || phone.length > 40))
+    return res.status(400).json({ error: "bad_phone" });
+  if (state && (typeof state !== "string" || state.length > 60))
+    return res.status(400).json({ error: "bad_state" });
+
+  const lead = {
+    at: new Date().toISOString(),
+    id: "lead_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 8),
+    service: service.trim(),
+    email: email.trim().toLowerCase(),
+    phone: (phone || "").trim() || null,
+    state: (state || "").trim() || null,
+    notes: (notes || "").trim() || null,
+    ip: (req.headers["x-forwarded-for"] || req.ip || "").toString().split(",")[0].trim(),
+    ua: (req.headers["user-agent"] || "").slice(0, 200),
+  };
+
+  try {
+    fs.mkdirSync(path.dirname(SERVICES_LEADS_PATH), { recursive: true });
+    fs.appendFileSync(SERVICES_LEADS_PATH, JSON.stringify(lead) + "\n");
+  } catch (e) {
+    console.error("[services-lead] failed to write JSONL:", e.message);
+    return res.status(500).json({ error: "persist_failed" });
+  }
+
+  // Best-effort email — don't block the response on it.
+  _emailLeadToSteve(lead).catch(e => console.warn("[services-lead] George email failed:", e.message));
+
+  res.json({ ok: true, id: lead.id, msg: "Got it. We'll reply to " + lead.email + " within 1 business day." });
+});
+
+// Admin: list recent services leads (auth-gated; for ops review).
+app.get("/api/admin/services-leads", requireAdmin, (req, res) => {
+  try {
+    const raw = fs.existsSync(SERVICES_LEADS_PATH) ? fs.readFileSync(SERVICES_LEADS_PATH, "utf8") : "";
+    const leads = raw.trim().split("\n").filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+    res.json({ count: leads.length, leads: leads.slice(-100).reverse() });
+  } catch (e) {
+    res.status(500).json({ error: "read_failed", msg: e.message });
+  }
+});
+
 // Public services-chat endpoint — answers small-business questions on the /services page.
 // Routes via smart-router (local Ollama default, Claude only if SMART_ROUTER_ALLOW_ANTHROPIC=1).
 const _smartRouter = require("./lib/smart-router");

← 4c50012 teaser: gold-accent CTA card linking to /services hub + foot  ·  back to Ventura Claw  ·  /services: JSON-LD ProfessionalService schema + 14 Offers; s 9c9548d →