← 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
A server/data/services-leads.jsonlM server/public/services.htmlM server/server.js
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 => ({"<":"<",">":">","&":"&"}[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 →