[object Object]

← back to Ventura Claw

/services: JSON-LD ProfessionalService schema + 14 Offers; sitemap + robots wired to venturaclaw.com via nginx; admin /admin/services-leads viewer page

9c9548ddadc16be8932820a57883b4289fc68723 · 2026-05-06 14:42:55 -0700 · Steve

Files touched

Diff

commit 9c9548ddadc16be8932820a57883b4289fc68723
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 6 14:42:55 2026 -0700

    /services: JSON-LD ProfessionalService schema + 14 Offers; sitemap + robots wired to venturaclaw.com via nginx; admin /admin/services-leads viewer page
---
 server/public/admin-services-leads.html | 236 ++++++++++++++++++++++++++++++++
 server/public/services.html             |  45 ++++++
 server/server.js                        |   3 +-
 3 files changed, 283 insertions(+), 1 deletion(-)

diff --git a/server/public/admin-services-leads.html b/server/public/admin-services-leads.html
new file mode 100644
index 0000000..2408ad5
--- /dev/null
+++ b/server/public/admin-services-leads.html
@@ -0,0 +1,236 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<title>Services Leads — VenturaClaw Admin</title>
+<style>
+  :root {
+    --bg: #0e0e10; --bg-2: #15151a; --bg-3: #1c1c22;
+    --rule: #2a2a32; --rule-2: #3a3a45;
+    --ink: #f4f1ea; --ink-soft: #d8d2c5; --ink-mute: #8b857a; --ink-faint: #5d574c;
+    --gold: #d4a04a; --gold-glow: #e6b96a;
+    --good: #6ec081; --bad: #c66c4d;
+    --serif: "Cormorant Garamond", Georgia, serif;
+    --mono: "JetBrains Mono", "SFMono-Regular", Menlo, monospace;
+    --sans: -apple-system, "SF Pro Text", system-ui, sans-serif;
+  }
+  *, *::before, *::after { box-sizing: border-box; }
+  html, body { margin: 0; background: var(--bg); color: var(--ink); font-family: var(--sans); font-weight: 300; min-height: 100vh; }
+  a { color: var(--gold); text-decoration: none; }
+  a:hover { color: var(--gold-glow); }
+
+  header {
+    padding: 22px 36px; display: flex; justify-content: space-between; align-items: center;
+    border-bottom: 1px solid var(--rule);
+    background: rgba(14,14,16,0.85); backdrop-filter: blur(8px);
+    position: sticky; top: 0; z-index: 50;
+  }
+  .brand { display: flex; gap: 12px; align-items: baseline; }
+  .brand .name { font-family: var(--serif); font-size: 22px; font-weight: 500; }
+  .brand .name em { color: var(--gold); font-style: italic; }
+  .brand .crumb { font-family: var(--mono); font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-mute); }
+  .brand .crumb::before { content: "/ "; color: var(--ink-faint); }
+  nav { display: flex; gap: 22px; align-items: center; }
+  nav a { font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-mute); }
+  nav a:hover { color: var(--gold); }
+
+  main { max-width: 1200px; margin: 0 auto; padding: 36px; }
+  h1 { font-family: var(--serif); font-weight: 400; font-size: 38px; letter-spacing: -0.012em; margin: 0 0 8px; }
+  h1 em { color: var(--gold); font-style: italic; }
+  .subhead { color: var(--ink-mute); font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; margin-bottom: 36px; }
+  .subhead b { color: var(--gold); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 14px; letter-spacing: 0; text-transform: none; margin-right: 4px; }
+
+  .toolbar {
+    display: flex; gap: 12px; margin-bottom: 18px; align-items: center; flex-wrap: wrap;
+  }
+  .toolbar input {
+    flex: 1; min-width: 240px;
+    background: var(--bg-2); color: var(--ink); border: 1px solid var(--rule); border-radius: 3px;
+    padding: 9px 14px; font-family: var(--sans); font-size: 13px; font-weight: 300; outline: none;
+  }
+  .toolbar input:focus { border-color: var(--gold); }
+  .toolbar select {
+    background: var(--bg-2); color: var(--ink); border: 1px solid var(--rule); border-radius: 3px;
+    padding: 9px 14px; font-family: var(--mono); font-size: 11px; letter-spacing: 0.06em; outline: none;
+  }
+  .toolbar .refresh-btn {
+    background: var(--bg-2); color: var(--ink-mute); border: 1px solid var(--rule); border-radius: 3px;
+    padding: 9px 16px; font-family: var(--mono); font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase; cursor: pointer;
+    transition: all 200ms;
+  }
+  .toolbar .refresh-btn:hover { color: var(--gold); border-color: var(--gold); }
+  .stats {
+    margin-left: auto;
+    font-family: var(--mono); font-size: 10px; letter-spacing: 0.12em; color: var(--ink-mute);
+  }
+  .stats b { color: var(--gold); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 14px; letter-spacing: 0; margin: 0 4px; }
+
+  table {
+    width: 100%; border-collapse: collapse;
+    background: var(--bg-2); border: 1px solid var(--rule);
+    font-family: var(--mono); font-size: 12px;
+  }
+  th, td {
+    padding: 12px 14px; text-align: left; vertical-align: top;
+    border-bottom: 1px solid var(--rule);
+  }
+  th {
+    background: var(--bg-3); color: var(--gold);
+    font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; font-size: 10px;
+  }
+  tr:hover td { background: var(--bg-3); }
+  td.email { color: var(--ink); }
+  td.email a { color: var(--gold); }
+  td.service {
+    font-family: var(--serif); font-size: 14px; color: var(--ink); font-weight: 500; letter-spacing: -0.005em;
+  }
+  td.notes {
+    color: var(--ink-soft); font-family: var(--sans); font-size: 13px;
+    max-width: 320px; white-space: pre-wrap; line-height: 1.45;
+  }
+  td.at { color: var(--ink-mute); font-size: 11px; white-space: nowrap; }
+  td.id { color: var(--ink-faint); font-size: 10px; }
+  .empty {
+    padding: 80px 36px; text-align: center; color: var(--ink-mute);
+    border: 1px dashed var(--rule); background: var(--bg-2);
+  }
+  .empty .glyph { font-size: 64px; color: var(--ink-faint); display: block; margin-bottom: 14px; }
+  .empty .msg { font-family: var(--serif); font-size: 22px; color: var(--ink-soft); margin-bottom: 6px; }
+  .empty .sub { font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-faint); }
+
+  .badge { display: inline-block; padding: 3px 8px; border-radius: 999px; font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; }
+  .badge.new { background: rgba(110,192,129,0.1); border: 1px solid var(--good); color: var(--good); }
+  .badge.old { background: var(--bg-3); border: 1px solid var(--rule); color: var(--ink-mute); }
+
+  .err { color: var(--bad); padding: 14px; border: 1px solid var(--bad); background: rgba(198,108,77,0.05); border-radius: 3px; margin-bottom: 18px; display: none; }
+  .err.show { display: block; }
+</style>
+</head>
+<body>
+
+<header>
+  <div class="brand">
+    <span class="name">Ventura<em>Claw</em></span>
+    <span class="crumb">Admin · Services Leads</span>
+  </div>
+  <nav>
+    <a href="/admin">Dashboard</a>
+    <a href="/admin/users">Users</a>
+    <a href="/admin/connectors">Connectors</a>
+    <a href="/admin/approvals">Approvals</a>
+    <a href="/admin/audit">Audit</a>
+  </nav>
+</header>
+
+<main>
+  <h1>Services <em>Leads</em></h1>
+  <div class="subhead"><b id="lead-count">…</b> total · live capture from /services hub</div>
+
+  <div class="err" id="err"></div>
+
+  <div class="toolbar">
+    <input type="search" id="filter" placeholder="filter by email, service, state, notes…" />
+    <select id="period">
+      <option value="all">all time</option>
+      <option value="24h">last 24h</option>
+      <option value="7d">last 7 days</option>
+      <option value="30d">last 30 days</option>
+    </select>
+    <button class="refresh-btn" id="refresh">↻ Refresh</button>
+    <span class="stats" id="stats"></span>
+  </div>
+
+  <div id="content"></div>
+</main>
+
+<script>
+  let LEADS = [];
+  const $ = id => document.getElementById(id);
+
+  async function load() {
+    $('err').classList.remove('show');
+    try {
+      const r = await fetch('/api/admin/services-leads', { credentials: 'include' });
+      if (!r.ok) {
+        const t = await r.text();
+        throw new Error(`HTTP ${r.status}: ${t.slice(0, 120)}`);
+      }
+      const j = await r.json();
+      LEADS = j.leads || [];
+      $('lead-count').textContent = j.count.toLocaleString();
+      render();
+    } catch (e) {
+      $('err').textContent = e.message;
+      $('err').classList.add('show');
+      $('content').innerHTML = '';
+    }
+  }
+
+  function render() {
+    const q = $('filter').value.trim().toLowerCase();
+    const period = $('period').value;
+    const cutoff = ({
+      '24h': Date.now() - 24*3600*1000,
+      '7d':  Date.now() - 7*24*3600*1000,
+      '30d': Date.now() - 30*24*3600*1000,
+      'all': 0,
+    })[period];
+
+    const filtered = LEADS.filter(l => {
+      if (cutoff && new Date(l.at).getTime() < cutoff) return false;
+      if (!q) return true;
+      const blob = `${l.service} ${l.email} ${l.state||''} ${l.notes||''} ${l.phone||''}`.toLowerCase();
+      return blob.includes(q);
+    });
+
+    $('stats').innerHTML = `<b>${filtered.length}</b>shown of <b>${LEADS.length}</b>loaded`;
+
+    if (filtered.length === 0) {
+      $('content').innerHTML = `<div class="empty">
+        <span class="glyph">∅</span>
+        <div class="msg">${LEADS.length === 0 ? 'No leads yet' : 'No leads match those filters'}</div>
+        <div class="sub">${LEADS.length === 0 ? 'Once someone hits "Get Started →" on /services, leads land here.' : 'Try widening the period or clearing the search.'}</div>
+      </div>`;
+      return;
+    }
+
+    const now = Date.now();
+    const isNew = at => (now - new Date(at).getTime()) < 24*3600*1000;
+    const fmt = at => {
+      const d = new Date(at);
+      return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
+    };
+    const esc = s => (s || '').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
+
+    $('content').innerHTML = `<table>
+      <thead><tr>
+        <th>When</th><th>Service</th><th>Email</th><th>Phone</th><th>State</th><th>Notes</th><th>Source</th>
+      </tr></thead>
+      <tbody>
+        ${filtered.map(l => `<tr>
+          <td class="at">
+            ${isNew(l.at) ? '<span class="badge new">New</span>' : '<span class="badge old">·</span>'}<br>
+            ${fmt(l.at)}
+          </td>
+          <td class="service">${esc(l.service)}</td>
+          <td class="email"><a href="mailto:${esc(l.email)}?subject=Re:%20${encodeURIComponent(l.service)}%20%E2%80%94%20VenturaClaw">${esc(l.email)}</a></td>
+          <td>${l.phone ? `<a href="tel:${esc(l.phone)}">${esc(l.phone)}</a>` : '<span style="color:var(--ink-faint)">—</span>'}</td>
+          <td>${esc(l.state) || '<span style="color:var(--ink-faint)">—</span>'}</td>
+          <td class="notes">${esc(l.notes) || '<span style="color:var(--ink-faint)">—</span>'}</td>
+          <td class="id">${esc(l.id)}<br><span style="color:var(--ink-faint)">${esc(l.ip || '')}</span></td>
+        </tr>`).join('')}
+      </tbody>
+    </table>`;
+  }
+
+  $('filter').addEventListener('input', render);
+  $('period').addEventListener('change', render);
+  $('refresh').addEventListener('click', load);
+
+  load();
+  setInterval(load, 30000); // auto-refresh every 30s
+</script>
+
+</body>
+</html>
diff --git a/server/public/services.html b/server/public/services.html
index 53b1166..6cd2123 100644
--- a/server/public/services.html
+++ b/server/public/services.html
@@ -5,6 +5,51 @@
 <meta name="viewport" content="width=device-width,initial-scale=1" />
 <title>Small-Business Services — VenturaClaw</title>
 <meta name="description" content="Form an LLC, register a DBA, file your BOI, open a business bank account, get insured, and 28 other small-business services. Free DIY guides, small flat-fee document preparation, and an AI assistant on every page. Not a law firm." />
+<meta property="og:type" content="website" />
+<meta property="og:title" content="Small-Business Services — VenturaClaw" />
+<meta property="og:description" content="30 services across 6 categories. Free DIY guides, small flat-fee filing. Plus an AI assistant. Not a law firm." />
+<meta property="og:url" content="https://venturaclaw.com/services" />
+<meta property="og:site_name" content="VenturaClaw" />
+<meta name="twitter:card" content="summary_large_image" />
+<link rel="canonical" href="https://venturaclaw.com/services" />
+<script type="application/ld+json">
+{
+  "@context": "https://schema.org",
+  "@type": "ProfessionalService",
+  "name": "VenturaClaw — Small-Business Services Hub",
+  "url": "https://venturaclaw.com/services",
+  "description": "Self-help document preparation service for small businesses: LLC formation, DBA, EIN, BOI/FinCEN, business licenses, sales tax permits, trademarks, and more. Not a law firm.",
+  "areaServed": "United States",
+  "priceRange": "$0–$99",
+  "hasOfferCatalog": {
+    "@type": "OfferCatalog",
+    "name": "Small-Business Services",
+    "itemListElement": [
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "LLC formation" }, "price": "99", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "DBA registration" }, "price": "49", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "EIN application walkthrough" }, "price": "0", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "S-Corp tax election" }, "price": "79", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Sole proprietorship registration" }, "price": "29", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Operating agreement drafting" }, "price": "49", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "BOI / FinCEN beneficial ownership filing" }, "price": "39", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Annual report filing" }, "price": "39", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Business license filing" }, "price": "79", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Sales tax permit filing" }, "price": "69", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Trademark search and filing" }, "price": "79", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Copyright registration" }, "price": "49", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Privacy policy and terms generator" }, "price": "39", "priceCurrency": "USD" },
+      { "@type": "Offer", "itemOffered": { "@type": "Service", "name": "Contract templates customization" }, "price": "29", "priceCurrency": "USD" }
+    ]
+  },
+  "disclaimer": "VenturaClaw is not a law firm and not your attorney. We are a self-help document preparation service. No legal advice is given.",
+  "publisher": {
+    "@type": "Organization",
+    "name": "VenturaClaw",
+    "email": "info@venturaclaw.com",
+    "url": "https://venturaclaw.com"
+  }
+}
+</script>
 <style>
   :root {
     --bg: #0e0e10; --bg-2: #15151a; --bg-3: #1c1c22; --bg-4: #25252e;
diff --git a/server/server.js b/server/server.js
index 9957017..41479c8 100644
--- a/server/server.js
+++ b/server/server.js
@@ -491,7 +491,7 @@ app.get("/robots.txt", (req, res) => {
   );
 });
 app.get("/sitemap.xml", (req, res) => {
-  const staticUrls = ["/", "/login", "/connectors", "/about", "/faq", "/docs", "/pricing", "/changelog", "/privacy", "/terms"];
+  const staticUrls = ["/", "/login", "/services", "/legal-notice", "/connectors", "/about", "/faq", "/docs", "/pricing", "/changelog", "/privacy", "/terms"];
   const connectorUrls = CONNECTORS.map(c => `/connectors/${c.id}`);
   const all = [...staticUrls, ...connectorUrls];
   const xml = `<?xml version="1.0" encoding="UTF-8"?>
@@ -521,6 +521,7 @@ app.get("/admin/users",           requireAdminPage, (req, res) => res.sendFile(p
 app.get("/admin/connectors",      requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-connectors.html")));
 app.get("/admin/approvals",       requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-approvals.html")));
 app.get("/admin/audit",           requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-audit.html")));
+app.get("/admin/services-leads",  requireAdminPage, (req, res) => res.sendFile(path.join(PUB, "admin-services-leads.html")));
 
 // public endpoints
 // Admin: force a re-fire of the preset cache prime. Useful when Mac1 evicts qwen3:14b

← 49f1891 /services lead capture: modal form + /api/services-lead JSON  ·  back to Ventura Claw  ·  rebrand sweep: users use steve@venturaclaw.com primary + ste a075901 →