sproutkit-code 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -160,6 +160,43 @@ var require_db = __commonJS({
160
160
  db2.exec("ALTER TABLE chat_messages ADD COLUMN tool_calls TEXT");
161
161
  } catch {
162
162
  }
163
+ db2.exec(`
164
+ CREATE TABLE IF NOT EXISTS invoices (
165
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
166
+ number TEXT UNIQUE NOT NULL,
167
+ lead_id INTEGER,
168
+ client_name TEXT NOT NULL,
169
+ client_email TEXT,
170
+ client_address TEXT,
171
+ line_items TEXT NOT NULL,
172
+ subtotal_pence INTEGER NOT NULL,
173
+ vat_pence INTEGER NOT NULL DEFAULT 0,
174
+ total_pence INTEGER NOT NULL,
175
+ currency TEXT NOT NULL DEFAULT 'GBP',
176
+ status TEXT NOT NULL DEFAULT 'draft',
177
+ issued_at TEXT,
178
+ due_at TEXT,
179
+ paid_at TEXT,
180
+ notes TEXT,
181
+ pdf_path TEXT,
182
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
183
+ );
184
+ `);
185
+ db2.exec(`
186
+ CREATE TABLE IF NOT EXISTS contact_log (
187
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
188
+ lead_id INTEGER NOT NULL,
189
+ direction TEXT NOT NULL,
190
+ channel TEXT,
191
+ outcome TEXT,
192
+ note TEXT,
193
+ at TEXT NOT NULL DEFAULT (datetime('now'))
194
+ );
195
+ `);
196
+ try {
197
+ db2.exec("CREATE INDEX IF NOT EXISTS idx_contact_log_lead ON contact_log(lead_id)");
198
+ } catch {
199
+ }
163
200
  for (const col of ["score INTEGER", "price_level TEXT", "business_status TEXT"]) {
164
201
  try {
165
202
  db2.exec(`ALTER TABLE leads ADD COLUMN ${col}`);
@@ -188,6 +225,12 @@ var require_db = __commonJS({
188
225
  // redeploys: SESSION_SECRET is regenerated every deploy, so deriving from
189
226
  // it would make the client's self-chosen password undecryptable each time.
190
227
  "auth_enc_key TEXT",
228
+ // Reply loop: when we last contacted them, and the latest outcome. Kept on
229
+ // the lead (as well as contact_log) so `followups` is a single cheap query.
230
+ "last_contacted_at TEXT",
231
+ "last_reply_at TEXT",
232
+ "reply_outcome TEXT",
233
+ "followed_up_count INTEGER DEFAULT 0",
191
234
  "booking_admin_url TEXT",
192
235
  "booking_admin_password TEXT",
193
236
  "handed_over_at TEXT",
@@ -31180,6 +31223,294 @@ var require_vercel_domains = __commonJS({
31180
31223
  }
31181
31224
  });
31182
31225
 
31226
+ // ../desktop/dist/main/lib/agency.js
31227
+ var require_agency = __commonJS({
31228
+ "../desktop/dist/main/lib/agency.js"(exports2) {
31229
+ "use strict";
31230
+ Object.defineProperty(exports2, "__esModule", { value: true });
31231
+ exports2.getAgencyProfile = getAgencyProfile2;
31232
+ exports2.setAgencyProfile = setAgencyProfile2;
31233
+ exports2.agencyConfigured = agencyConfigured2;
31234
+ exports2.siteCredit = siteCredit;
31235
+ exports2.formatMoney = formatMoney2;
31236
+ var db_1 = require_db();
31237
+ var KEYS = {
31238
+ name: "agency_name",
31239
+ logoPath: "agency_logo_path",
31240
+ accent: "agency_accent",
31241
+ email: "agency_email",
31242
+ phone: "agency_phone",
31243
+ website: "agency_website",
31244
+ address: "agency_address",
31245
+ companyNumber: "agency_company_number",
31246
+ vatNumber: "agency_vat_number",
31247
+ bankName: "agency_bank_name",
31248
+ bankAccount: "agency_bank_account",
31249
+ bankSortCode: "agency_bank_sort_code",
31250
+ paymentTerms: "agency_payment_terms"
31251
+ };
31252
+ function read(key) {
31253
+ const row = (0, db_1.getDb)().get("SELECT value FROM settings WHERE key = ?", [key]);
31254
+ const v = row?.value?.trim();
31255
+ return v ? v : null;
31256
+ }
31257
+ function write(key, value) {
31258
+ const db2 = (0, db_1.getDb)();
31259
+ if (value === null || value === "") {
31260
+ db2.run("DELETE FROM settings WHERE key = ?", [key]);
31261
+ return;
31262
+ }
31263
+ db2.run("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [key, value]);
31264
+ }
31265
+ function getAgencyProfile2() {
31266
+ const rate = read("agency_vat_rate");
31267
+ return {
31268
+ name: read(KEYS.name),
31269
+ logoPath: read(KEYS.logoPath),
31270
+ accent: read(KEYS.accent),
31271
+ email: read(KEYS.email),
31272
+ phone: read(KEYS.phone),
31273
+ website: read(KEYS.website),
31274
+ address: read(KEYS.address),
31275
+ companyNumber: read(KEYS.companyNumber),
31276
+ vatNumber: read(KEYS.vatNumber),
31277
+ vatRate: rate === null ? null : Number(rate) || null,
31278
+ bankName: read(KEYS.bankName),
31279
+ bankAccount: read(KEYS.bankAccount),
31280
+ bankSortCode: read(KEYS.bankSortCode),
31281
+ paymentTerms: read(KEYS.paymentTerms),
31282
+ currency: read("agency_currency") ?? "GBP"
31283
+ };
31284
+ }
31285
+ function setAgencyProfile2(patch) {
31286
+ for (const [field, key] of Object.entries(KEYS)) {
31287
+ if (field in patch)
31288
+ write(key, patch[field] ?? null);
31289
+ }
31290
+ if ("vatRate" in patch) {
31291
+ write("agency_vat_rate", patch.vatRate == null ? null : String(patch.vatRate));
31292
+ }
31293
+ if ("currency" in patch)
31294
+ write("agency_currency", patch.currency ?? null);
31295
+ return getAgencyProfile2();
31296
+ }
31297
+ function agencyConfigured2() {
31298
+ return !!getAgencyProfile2().name;
31299
+ }
31300
+ function siteCredit() {
31301
+ const p = getAgencyProfile2();
31302
+ if (!p.name)
31303
+ return { label: "Sproutkit", url: "https://sproutkit.app" };
31304
+ return { label: p.name, url: p.website };
31305
+ }
31306
+ function formatMoney2(pence, currency = "GBP") {
31307
+ const symbols = { GBP: "\xA3", USD: "$", EUR: "\u20AC" };
31308
+ const sym = symbols[currency] ?? "";
31309
+ const value = (pence / 100).toFixed(2);
31310
+ return `${sym}${value}`;
31311
+ }
31312
+ }
31313
+ });
31314
+
31315
+ // ../desktop/dist/main/lib/analytics-runtime.js
31316
+ var require_analytics_runtime = __commonJS({
31317
+ "../desktop/dist/main/lib/analytics-runtime.js"(exports2) {
31318
+ "use strict";
31319
+ Object.defineProperty(exports2, "__esModule", { value: true });
31320
+ exports2.TRACK_SNIPPET = exports2.API_STATS_JS = exports2.API_TRACK_JS = void 0;
31321
+ exports2.API_TRACK_JS = `import { list, put } from "@vercel/blob";
31322
+
31323
+ export const config = { runtime: "nodejs" };
31324
+
31325
+ const PFX = process.env.SITE_ID ? "sites/" + process.env.SITE_ID + "/" : "";
31326
+ const STATS_KEY = PFX + "stats.json";
31327
+
31328
+ // Fixed whitelist: an open counter would let anyone write arbitrary keys into
31329
+ // the store and inflate the numbers the agency reports to their client.
31330
+ const EVENTS = ["view", "call", "book", "directions", "email", "menu"];
31331
+
31332
+ // Keep 400 days so a year-on-year comparison is possible, then drop the tail \u2014
31333
+ // the file has to stay small enough to read on every request.
31334
+ const MAX_DAYS = 400;
31335
+
31336
+ function today() {
31337
+ return new Date().toISOString().slice(0, 10);
31338
+ }
31339
+
31340
+ async function readStats() {
31341
+ try {
31342
+ const { blobs } = await list({ prefix: STATS_KEY, limit: 1 });
31343
+ const target = blobs.find((b) => b.pathname === STATS_KEY);
31344
+ if (!target) return { days: {} };
31345
+ const res = await fetch(target.url, { cache: "no-store" });
31346
+ if (!res.ok) return { days: {} };
31347
+ const j = await res.json();
31348
+ return j && typeof j === "object" && j.days ? j : { days: {} };
31349
+ } catch {
31350
+ return { days: {} };
31351
+ }
31352
+ }
31353
+
31354
+ function readBody(req) {
31355
+ return new Promise((resolve) => {
31356
+ const chunks = [];
31357
+ req.on("data", (c) => chunks.push(c));
31358
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
31359
+ req.on("error", () => resolve(""));
31360
+ });
31361
+ }
31362
+
31363
+ export default async function handler(req, res) {
31364
+ // Never let tracking hold up or break a page.
31365
+ res.setHeader("Cache-Control", "no-store");
31366
+ if (req.method !== "POST") {
31367
+ res.status(405).json({ error: "method_not_allowed" });
31368
+ return;
31369
+ }
31370
+ let body = req.body;
31371
+ if (typeof body === "string") {
31372
+ try { body = JSON.parse(body); } catch { body = null; }
31373
+ } else if (!body) {
31374
+ const raw = await readBody(req);
31375
+ try { body = raw ? JSON.parse(raw) : {}; } catch { body = {}; }
31376
+ }
31377
+ const event = String((body && body.event) || "");
31378
+ if (!EVENTS.includes(event)) {
31379
+ res.status(400).json({ error: "unknown_event" });
31380
+ return;
31381
+ }
31382
+
31383
+ const stats = await readStats();
31384
+ const day = today();
31385
+ if (!stats.days[day]) stats.days[day] = {};
31386
+ stats.days[day][event] = (stats.days[day][event] || 0) + 1;
31387
+
31388
+ const days = Object.keys(stats.days).sort();
31389
+ if (days.length > MAX_DAYS) {
31390
+ for (const d of days.slice(0, days.length - MAX_DAYS)) delete stats.days[d];
31391
+ }
31392
+
31393
+ try {
31394
+ await put(STATS_KEY, JSON.stringify(stats), {
31395
+ access: "public",
31396
+ addRandomSuffix: false,
31397
+ allowOverwrite: true,
31398
+ contentType: "application/json",
31399
+ });
31400
+ } catch {
31401
+ // Losing a count is fine; erroring at the visitor is not.
31402
+ }
31403
+ res.status(204).end();
31404
+ }
31405
+ `;
31406
+ exports2.API_STATS_JS = `import { list } from "@vercel/blob";
31407
+ import { createHmac, timingSafeEqual } from "node:crypto";
31408
+
31409
+ export const config = { runtime: "nodejs" };
31410
+
31411
+ const PFX = process.env.SITE_ID ? "sites/" + process.env.SITE_ID + "/" : "";
31412
+ const STATS_KEY = PFX + "stats.json";
31413
+
31414
+ function verifySession(req) {
31415
+ const cookie = req.headers.cookie || "";
31416
+ const match = cookie.match(/sk_admin=([^;]+)/);
31417
+ if (!match) return false;
31418
+ const secret = process.env.SESSION_SECRET;
31419
+ if (!secret) return false;
31420
+ const [payload, sig] = match[1].split(".");
31421
+ if (!payload || !sig) return false;
31422
+ try {
31423
+ const expected = createHmac("sha256", secret).update(payload).digest();
31424
+ const provided = Buffer.from(sig.replace(/-/g, "+").replace(/_/g, "/"), "base64");
31425
+ if (expected.length !== provided.length) return false;
31426
+ if (!timingSafeEqual(expected, provided)) return false;
31427
+ const data = JSON.parse(
31428
+ Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
31429
+ );
31430
+ return typeof data.exp === "number" && data.exp > Date.now() / 1000;
31431
+ } catch {
31432
+ return false;
31433
+ }
31434
+ }
31435
+
31436
+ function windowTotals(days, from, to) {
31437
+ const out = { view: 0, call: 0, book: 0, directions: 0, email: 0, menu: 0 };
31438
+ for (const [day, counts] of Object.entries(days || {})) {
31439
+ if (day < from || day > to) continue;
31440
+ for (const [k, v] of Object.entries(counts || {})) {
31441
+ if (k in out) out[k] += Number(v) || 0;
31442
+ }
31443
+ }
31444
+ return out;
31445
+ }
31446
+
31447
+ function shiftDays(n) {
31448
+ const d = new Date();
31449
+ d.setUTCDate(d.getUTCDate() + n);
31450
+ return d.toISOString().slice(0, 10);
31451
+ }
31452
+
31453
+ export default async function handler(req, res) {
31454
+ if (!verifySession(req)) {
31455
+ res.status(401).json({ error: "unauthorized" });
31456
+ return;
31457
+ }
31458
+ let stats = { days: {} };
31459
+ try {
31460
+ const { blobs } = await list({ prefix: STATS_KEY, limit: 1 });
31461
+ const target = blobs.find((b) => b.pathname === STATS_KEY);
31462
+ if (target) {
31463
+ const r = await fetch(target.url, { cache: "no-store" });
31464
+ if (r.ok) {
31465
+ const j = await r.json();
31466
+ if (j && j.days) stats = j;
31467
+ }
31468
+ }
31469
+ } catch {
31470
+ // fall through with empty stats
31471
+ }
31472
+
31473
+ const today = shiftDays(0);
31474
+ res.setHeader("Cache-Control", "no-store");
31475
+ res.status(200).json({
31476
+ last30: windowTotals(stats.days, shiftDays(-29), today),
31477
+ // The comparison window is what makes the number mean something.
31478
+ prev30: windowTotals(stats.days, shiftDays(-59), shiftDays(-30)),
31479
+ last7: windowTotals(stats.days, shiftDays(-6), today),
31480
+ allTime: windowTotals(stats.days, "0000-00-00", "9999-99-99"),
31481
+ days: stats.days,
31482
+ });
31483
+ }
31484
+ `;
31485
+ exports2.TRACK_SNIPPET = `<script>
31486
+ (function(){
31487
+ function send(ev){
31488
+ try {
31489
+ var body = JSON.stringify({ event: ev });
31490
+ if (navigator.sendBeacon) {
31491
+ navigator.sendBeacon("/api/track", new Blob([body], { type: "application/json" }));
31492
+ } else {
31493
+ fetch("/api/track", { method:"POST", headers:{"Content-Type":"application/json"}, body: body, keepalive: true });
31494
+ }
31495
+ } catch(e){}
31496
+ }
31497
+ send("view");
31498
+ document.addEventListener("click", function(e){
31499
+ var a = e.target && e.target.closest ? e.target.closest("a,button") : null;
31500
+ if (!a) return;
31501
+ var href = (a.getAttribute("href") || "").toLowerCase();
31502
+ var text = (a.textContent || "").toLowerCase();
31503
+ if (href.indexOf("tel:") === 0) return send("call");
31504
+ if (href.indexOf("mailto:") === 0) return send("email");
31505
+ if (href.indexOf("/book") === 0 || /book|reserve|appointment/.test(text)) return send("book");
31506
+ if (href.indexOf("maps.google") > -1 || href.indexOf("goo.gl/maps") > -1 || /directions/.test(text)) return send("directions");
31507
+ if (/menu/.test(text)) return send("menu");
31508
+ }, true);
31509
+ })();
31510
+ </script>`;
31511
+ }
31512
+ });
31513
+
31183
31514
  // ../../node_modules/bcryptjs/umd/index.js
31184
31515
  var require_umd = __commonJS({
31185
31516
  "../../node_modules/bcryptjs/umd/index.js"(exports2, module2) {
@@ -33688,7 +34019,7 @@ export default async function handler(req, res) {
33688
34019
  <head>
33689
34020
  <meta charset="UTF-8" />
33690
34021
  <meta name="viewport" content="width=device-width,initial-scale=1" />
33691
- <title>Site editor</title>
34022
+ <title>__ADMIN_TITLE__</title>
33692
34023
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" />
33693
34024
  <style>
33694
34025
  :root {
@@ -34334,6 +34665,67 @@ export default async function handler(req, res) {
34334
34665
  );
34335
34666
  }
34336
34667
 
34668
+ // What the site actually DID this month. This is the renewal conversation:
34669
+ // "37 calls, up from 21" beats any amount of design talk.
34670
+ function InsightsPanel() {
34671
+ var data = useState(null); var loading = useState(true); var err = useState("");
34672
+ useEffect(function () {
34673
+ fetch("/api/stats", { credentials: "same-origin" })
34674
+ .then(function (r) { if (!r.ok) throw new Error("stats " + r.status); return r.json(); })
34675
+ .then(function (d) { data[1](d); loading[1](false); })
34676
+ .catch(function () { err[1]("Couldn't load your numbers just yet."); loading[1](false); });
34677
+ }, []);
34678
+
34679
+ if (loading[0]) return h("div", null, "Loading your numbers\u2026");
34680
+ if (err[0] || !data[0]) return h("div", { style: { color: "var(--muted)" } }, err[0] || "No data yet.");
34681
+
34682
+ var d = data[0];
34683
+ var LABELS = {
34684
+ view: "Page views", call: "Phone calls", book: "Booking clicks",
34685
+ email: "Email clicks", directions: "Directions", menu: "Menu views"
34686
+ };
34687
+ var order = ["call", "book", "view", "directions", "email", "menu"];
34688
+
34689
+ function delta(now, before) {
34690
+ if (!before) return now > 0 ? { text: "new", good: true } : null;
34691
+ var pct = Math.round(((now - before) / before) * 100);
34692
+ if (pct === 0) return { text: "no change", good: true };
34693
+ return { text: (pct > 0 ? "+" : "") + pct + "%", good: pct >= 0 };
34694
+ }
34695
+
34696
+ var anything = order.some(function (k) { return (d.last30[k] || 0) > 0; });
34697
+
34698
+ return h("div", null,
34699
+ h("div", { className: "bar" },
34700
+ h("div", null,
34701
+ h("div", { style: { fontSize: 15, fontWeight: 600 } }, "Insights"),
34702
+ h("div", { style: { fontSize: 12, color: "var(--muted)" } }, "Last 30 days, compared with the 30 before")
34703
+ )
34704
+ ),
34705
+ !anything
34706
+ ? h("div", { style: { color: "var(--muted)", fontSize: 13 } },
34707
+ "No activity recorded yet. Numbers appear here once people start visiting the site.")
34708
+ : h("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(150px,1fr))", gap: 12 } },
34709
+ order.filter(function (k) { return (d.last30[k] || 0) > 0 || k === "call" || k === "view"; }).map(function (k) {
34710
+ var now = d.last30[k] || 0;
34711
+ var chg = delta(now, d.prev30[k] || 0);
34712
+ return h("div", {
34713
+ key: k,
34714
+ style: { border: "1px solid var(--border)", borderRadius: 10, padding: "14px 16px", background: "var(--panel)" }
34715
+ },
34716
+ h("div", { style: { fontSize: 12, color: "var(--muted)", marginBottom: 6 } }, LABELS[k] || k),
34717
+ h("div", { style: { fontSize: 26, fontWeight: 600, lineHeight: 1.1 } }, String(now)),
34718
+ chg ? h("div", {
34719
+ style: { fontSize: 12, marginTop: 4, color: chg.good ? "var(--accent)" : "#b91c1c" }
34720
+ }, chg.text + " vs previous 30 days") : null
34721
+ );
34722
+ })
34723
+ ),
34724
+ h("div", { style: { fontSize: 12, color: "var(--muted)", marginTop: 18 } },
34725
+ "Counts only \u2014 no cookies, no personal data, nothing shared with anyone.")
34726
+ );
34727
+ }
34728
+
34337
34729
  function TabBar(props) {
34338
34730
  return h("div", { style: { display: "flex", gap: 4, marginBottom: 24, borderBottom: "1px solid var(--border)", paddingBottom: 12 } },
34339
34731
  props.tabs.map(function (t) {
@@ -34440,10 +34832,19 @@ export default async function handler(req, res) {
34440
34832
  // booking page is the site root rather than /book.
34441
34833
  if (!ctl.available || !ctl.content) {
34442
34834
  return h("div", { className: "app" },
34443
- h(TabBar, { tabs: [{ id: "bookings", label: "Bookings" }, { id: "password", label: "Password" }], section: section }),
34835
+ h(TabBar, {
34836
+ tabs: [
34837
+ { id: "bookings", label: "Bookings" },
34838
+ { id: "insights", label: "Insights" },
34839
+ { id: "password", label: "Password" }
34840
+ ],
34841
+ section: section
34842
+ }),
34444
34843
  section[0] === "password"
34445
34844
  ? h(PasswordPanel, null)
34446
- : h(BookingsPanel, { bookingPath: "/" })
34845
+ : section[0] === "insights"
34846
+ ? h(InsightsPanel, null)
34847
+ : h(BookingsPanel, { bookingPath: "/" })
34447
34848
  );
34448
34849
  }
34449
34850
 
@@ -34453,6 +34854,7 @@ export default async function handler(req, res) {
34453
34854
  tabs: [
34454
34855
  { id: "content", label: "Site content" },
34455
34856
  { id: "bookings", label: "Bookings" },
34857
+ { id: "insights", label: "Insights" },
34456
34858
  { id: "password", label: "Password" }
34457
34859
  ],
34458
34860
  section: section
@@ -34461,7 +34863,9 @@ export default async function handler(req, res) {
34461
34863
  ? h(Editor, { content: ctl.content, setContent: ctl.setContent })
34462
34864
  : section[0] === "password"
34463
34865
  ? h(PasswordPanel, null)
34464
- : h(BookingsPanel, null)
34866
+ : section[0] === "insights"
34867
+ ? h(InsightsPanel, null)
34868
+ : h(BookingsPanel, null)
34465
34869
  );
34466
34870
  }
34467
34871
 
@@ -80274,6 +80678,8 @@ var require_builder = __commonJS({
80274
80678
  var models_1 = require_models();
80275
80679
  var cms_template_1 = require_cms_template();
80276
80680
  var vercel_domains_1 = require_vercel_domains();
80681
+ var agency_1 = require_agency();
80682
+ var analytics_runtime_1 = require_analytics_runtime();
80277
80683
  var cms_runtime_1 = require_cms_runtime();
80278
80684
  var booking_runtime_1 = require_booking_runtime();
80279
80685
  function siteUidFor(leadId) {
@@ -80357,6 +80763,11 @@ var require_builder = __commonJS({
80357
80763
  const pattern = new RegExp(`https?:\\/\\/[^\\s"'\`)]*(${STOCK_HOSTS.map((h) => h.replace(/\./g, "\\.")).join("|")})[^\\s"'\`)]*`, "gi");
80358
80764
  return code.replace(pattern, "");
80359
80765
  }
80766
+ function adminHtml() {
80767
+ const p = (0, agency_1.getAgencyProfile)();
80768
+ const title = p.name ? `${p.name} \u2014 site manager` : "Site editor";
80769
+ return cms_runtime_1.ADMIN_INDEX_HTML.replace("__ADMIN_TITLE__", title.replace(/[<>&]/g, ""));
80770
+ }
80360
80771
  function bannerFor(lead) {
80361
80772
  let handedOver = false;
80362
80773
  try {
@@ -80371,8 +80782,10 @@ var require_builder = __commonJS({
80371
80782
  return "";
80372
80783
  const esc = (v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
80373
80784
  const label = `Preview site for ${esc(businessName)}`;
80785
+ const credit = (0, agency_1.siteCredit)();
80786
+ const creditHtml = credit.url ? `<a href="${esc(credit.url.startsWith("http") ? credit.url : "https://" + credit.url)}" style="all:unset;color:#a7f3d0;text-decoration:underline;cursor:pointer">${esc(credit.label)}</a>` : esc(credit.label);
80374
80787
  return `<div id="sproutkit-demo-banner" role="note" style="all:initial;display:block;box-sizing:border-box;width:100%;background:#1f2937;color:#f9fafb;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;font-size:13px;line-height:1.4;padding:10px 16px;text-align:center;letter-spacing:.01em">
80375
- <strong style="all:unset;font-weight:600">DEMO</strong> \xB7 ${label} \u2014 not licensed for live use. Built with <a href="https://sproutkit.app" style="all:unset;color:#a7f3d0;text-decoration:underline;cursor:pointer">Sproutkit</a>. Contact us to go live with your own domain.
80788
+ <strong style="all:unset;font-weight:600">DEMO</strong> \xB7 ${label} \u2014 not licensed for live use. Built by ${creditHtml}. Contact us to go live with your own domain.
80376
80789
  </div>`;
80377
80790
  }
80378
80791
  function snapshotDir(leadId) {
@@ -80541,6 +80954,8 @@ var require_builder = __commonJS({
80541
80954
  async function buildAndDeploySite(args) {
80542
80955
  const { lead, anthropicKey, vercelToken, placesKey, customInstructions, onProgress, includeCms, blobToken, reuseAdminPassword, skipContentExtraction, multipage, pages, skipQa } = args;
80543
80956
  const pageList = multipage && (!pages || pages.length === 0) ? ["Home", "Services", "About", "Contact"] : pages ?? [];
80957
+ const credit = (0, agency_1.siteCredit)();
80958
+ const footerCredit = credit.url ? `Built by ${credit.label} \xB7 ${credit.url}` : `Built by ${credit.label}`;
80544
80959
  if (includeCms && !blobToken) {
80545
80960
  throw new Error("CMS is enabled but no Vercel Blob token is set. Add it in Settings \u2192 Vercel Blob token.");
80546
80961
  }
@@ -80743,7 +81158,7 @@ RULES for structure:
80743
81158
  (tel:${lead.phone} for trades, book for appointments, menu/order for food).
80744
81159
  - Non-negotiable floor, however you arrange it: the business name, what they do,
80745
81160
  where they are (${lead.city}), the phone as a tel: link, and a footer ending with
80746
- "Built with Sproutkit \xB7 sproutkit.app" in muted text.
81161
+ "${footerCredit}" in muted text.
80747
81162
  - Use the real reviews and hours given above where they earn their place \u2014 not in a
80748
81163
  fixed slot. Photos: only from PHOTOS, sized per the archetype (see gallery rule:
80749
81164
  if PHOTOS.length < 2 there is no gallery section at all).
@@ -80948,7 +81363,7 @@ ${prompt}` : prompt;
80948
81363
  onProgress("Deploying to Vercel...");
80949
81364
  const indexHtml = `<!DOCTYPE html>
80950
81365
  <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head>
80951
- <body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
81366
+ <body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script>${analytics_runtime_1.TRACK_SNIPPET}</body></html>`;
80952
81367
  const mainJsx = `import { StrictMode } from 'react';
80953
81368
  import { createRoot } from 'react-dom/client';
80954
81369
  import App from './App.jsx';
@@ -81056,8 +81471,10 @@ export default defineConfig({ plugins: [react()] });`;
81056
81471
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81057
81472
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81058
81473
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
81474
+ { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81475
+ { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
81059
81476
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" },
81060
- { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" },
81477
+ { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
81061
81478
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" }
81062
81479
  ];
81063
81480
  cmsAdmin = { url: "", password: adminPassword };
@@ -81145,7 +81562,7 @@ export default defineConfig({ plugins: [react()] });`;
81145
81562
  { source: "/book/", destination: "/book/index.html" }
81146
81563
  ]
81147
81564
  });
81148
- const indexHtmlCms = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
81565
+ const indexHtmlCms = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script>${analytics_runtime_1.TRACK_SNIPPET}</body></html>`;
81149
81566
  const mainJsxCms = `import { StrictMode } from 'react';
81150
81567
  import { createRoot } from 'react-dom/client';
81151
81568
  import App from './App.jsx';
@@ -81153,7 +81570,7 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
81153
81570
  const viteConfigCms = `import { defineConfig } from 'vite';
81154
81571
  import react from '@vitejs/plugin-react';
81155
81572
  export default defineConfig({ plugins: [react()] });`;
81156
- filesToRedeploy.push({ file: "index.html", data: indexHtmlCms, encoding: "utf-8" }, { file: "src/main.jsx", data: mainJsxCms, encoding: "utf-8" }, { file: "vite.config.js", data: viteConfigCms, encoding: "utf-8" }, { file: "package.json", data: packageJsonCms, encoding: "utf-8" }, { file: "vercel.json", data: vercelJson, encoding: "utf-8" }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81573
+ filesToRedeploy.push({ file: "index.html", data: indexHtmlCms, encoding: "utf-8" }, { file: "src/main.jsx", data: mainJsxCms, encoding: "utf-8" }, { file: "vite.config.js", data: viteConfigCms, encoding: "utf-8" }, { file: "package.json", data: packageJsonCms, encoding: "utf-8" }, { file: "vercel.json", data: vercelJson, encoding: "utf-8" }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81157
81574
  if (!filesToRedeploy.some((f) => f.file === "src/App.jsx")) {
81158
81575
  throw new Error("Couldn't fetch src/App.jsx from the existing deployment. Try Regenerate website first, then Redeploy CMS.");
81159
81576
  }
@@ -81223,7 +81640,7 @@ export default defineConfig({ plugins: [react()] });`;
81223
81640
  snapshot[appJsxIndex] = { ...snapshot[appJsxIndex], data: modifiedApp };
81224
81641
  saveSnapshot(lead.id, snapshot);
81225
81642
  const files = snapshot.map((f) => ({ file: f.file, data: f.data, encoding: f.encoding }));
81226
- const indexHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
81643
+ const indexHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script>${analytics_runtime_1.TRACK_SNIPPET}</body></html>`;
81227
81644
  const mainJsx = `import { StrictMode } from 'react';
81228
81645
  import { createRoot } from 'react-dom/client';
81229
81646
  import App from './App.jsx';
@@ -81305,12 +81722,14 @@ export default defineConfig({ plugins: [react()] });`;
81305
81722
  },
81306
81723
  { file: "public/book/app.js", data: booking_runtime_1.BOOK_APP_JS, encoding: "utf-8" },
81307
81724
  // Refresh the CMS admin app (contains the Bookings tab now)
81308
- { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" },
81725
+ { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
81309
81726
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" },
81310
81727
  { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" },
81311
81728
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81312
81729
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81313
81730
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
81731
+ { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81732
+ { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
81314
81733
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }
81315
81734
  );
81316
81735
  onProgress("Deploying\u2026");
@@ -81366,11 +81785,13 @@ export default defineConfig({ plugins: [react()] });`;
81366
81785
  { file: "api/booking-config.js", data: booking_runtime_1.API_BOOKING_CONFIG_JS, encoding: "utf-8" },
81367
81786
  // The client's own dashboard: view bookings, edit services/hours. Same
81368
81787
  // admin the CMS sites use — it already has a Bookings tab.
81369
- { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" },
81788
+ { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
81370
81789
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" },
81371
81790
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81372
81791
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81373
81792
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
81793
+ { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81794
+ { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
81374
81795
  // Serve /admin without a trailing-slash redirect dance.
81375
81796
  {
81376
81797
  file: "vercel.json",
@@ -81512,7 +81933,7 @@ export default defineConfig({ plugins: [react()] });`;
81512
81933
  deps["@vercel/blob"] = "^0.23.4";
81513
81934
  deps.bcryptjs = "^2.4.3";
81514
81935
  }
81515
- const indexHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
81936
+ const indexHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script>${analytics_runtime_1.TRACK_SNIPPET}</body></html>`;
81516
81937
  files.push({ file: "index.html", data: indexHtml, encoding: "utf-8" }, {
81517
81938
  file: "src/main.jsx",
81518
81939
  data: `import { StrictMode } from 'react';
@@ -81552,7 +81973,7 @@ export default defineConfig({ plugins: [react()] });`,
81552
81973
  ]
81553
81974
  }),
81554
81975
  encoding: "utf-8"
81555
- }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81976
+ }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81556
81977
  const env = {
81557
81978
  BLOB_READ_WRITE_TOKEN: blobToken,
81558
81979
  ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
@@ -82235,6 +82656,541 @@ var require_lead_search = __commonJS({
82235
82656
  }
82236
82657
  });
82237
82658
 
82659
+ // ../desktop/dist/main/lib/followup.js
82660
+ var require_followup = __commonJS({
82661
+ "../desktop/dist/main/lib/followup.js"(exports2) {
82662
+ "use strict";
82663
+ Object.defineProperty(exports2, "__esModule", { value: true });
82664
+ exports2.logContact = logContact;
82665
+ exports2.dueFollowUps = dueFollowUps2;
82666
+ exports2.contactHistory = contactHistory2;
82667
+ exports2.outreachStats = outreachStats2;
82668
+ var db_1 = require_db();
82669
+ var STATUS_FOR = {
82670
+ interested: "replied",
82671
+ callback: "replied",
82672
+ // Not a lost cause forever, but out of the active funnel.
82673
+ not_interested: "dead",
82674
+ wrong_number: "dead",
82675
+ // Silence isn't an outcome that changes the stage — they're still 'contacted'.
82676
+ no_answer: null
82677
+ };
82678
+ function logContact(args) {
82679
+ const db2 = (0, db_1.getDb)();
82680
+ const lead = db2.get("SELECT id FROM leads WHERE id = ?", [args.leadId]);
82681
+ if (!lead)
82682
+ throw new Error(`Lead #${args.leadId} not found`);
82683
+ db2.run("INSERT INTO contact_log (lead_id, direction, channel, outcome, note) VALUES (?, ?, ?, ?, ?)", [args.leadId, args.direction, args.channel ?? null, args.outcome ?? null, args.note ?? null]);
82684
+ if (args.direction === "out") {
82685
+ db2.run(`UPDATE leads
82686
+ SET last_contacted_at = datetime('now'),
82687
+ followed_up_count = COALESCE(followed_up_count, 0) + 1
82688
+ WHERE id = ?`, [args.leadId]);
82689
+ return;
82690
+ }
82691
+ const nextStatus = args.outcome ? STATUS_FOR[args.outcome] : null;
82692
+ db2.run(`UPDATE leads
82693
+ SET last_reply_at = datetime('now'),
82694
+ reply_outcome = ?,
82695
+ status = CASE
82696
+ -- Never drag a client BACKWARDS out of won.
82697
+ WHEN status = 'won' THEN status
82698
+ WHEN ? IS NULL THEN status
82699
+ ELSE ?
82700
+ END,
82701
+ dead_at = CASE WHEN ? = 'dead' THEN datetime('now') ELSE dead_at END
82702
+ WHERE id = ?`, [args.outcome ?? null, nextStatus, nextStatus, nextStatus, args.leadId]);
82703
+ }
82704
+ function dueFollowUps2(afterDays = 3, limit = 50) {
82705
+ return (0, db_1.getDb)().all(`SELECT id, name, city, phone, email, status, site_url, booking_url,
82706
+ last_contacted_at, followed_up_count,
82707
+ CAST(julianday('now') - julianday(last_contacted_at) AS INTEGER) AS days_since
82708
+ FROM leads
82709
+ WHERE last_contacted_at IS NOT NULL
82710
+ AND last_reply_at IS NULL
82711
+ AND status NOT IN ('dead', 'won')
82712
+ AND julianday('now') - julianday(last_contacted_at) >= ?
82713
+ ORDER BY last_contacted_at ASC
82714
+ LIMIT ?`, [afterDays, limit]);
82715
+ }
82716
+ function contactHistory2(leadId) {
82717
+ return (0, db_1.getDb)().all("SELECT * FROM contact_log WHERE lead_id = ? ORDER BY at DESC", [leadId]);
82718
+ }
82719
+ function outreachStats2() {
82720
+ const db2 = (0, db_1.getDb)();
82721
+ const one = (sql, params = []) => Number(db2.get(sql, params)?.n ?? 0);
82722
+ const contacted = one("SELECT COUNT(*) n FROM leads WHERE last_contacted_at IS NOT NULL");
82723
+ const replied = one("SELECT COUNT(*) n FROM leads WHERE last_reply_at IS NOT NULL");
82724
+ const interested = one("SELECT COUNT(*) n FROM leads WHERE reply_outcome IN ('interested','callback')");
82725
+ const won = one("SELECT COUNT(*) n FROM leads WHERE status = 'won'");
82726
+ const awaitingReply = one(`SELECT COUNT(*) n FROM leads
82727
+ WHERE last_contacted_at IS NOT NULL AND last_reply_at IS NULL
82728
+ AND status NOT IN ('dead','won')`);
82729
+ return {
82730
+ contacted,
82731
+ replied,
82732
+ interested,
82733
+ won,
82734
+ awaitingReply,
82735
+ replyRatePct: contacted ? Math.round(replied / contacted * 100) : 0
82736
+ };
82737
+ }
82738
+ }
82739
+ });
82740
+
82741
+ // ../desktop/dist/main/lib/invoicing.js
82742
+ var require_invoicing = __commonJS({
82743
+ "../desktop/dist/main/lib/invoicing.js"(exports2) {
82744
+ "use strict";
82745
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? (function(o, m, k, k2) {
82746
+ if (k2 === void 0) k2 = k;
82747
+ var desc = Object.getOwnPropertyDescriptor(m, k);
82748
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
82749
+ desc = { enumerable: true, get: function() {
82750
+ return m[k];
82751
+ } };
82752
+ }
82753
+ Object.defineProperty(o, k2, desc);
82754
+ }) : (function(o, m, k, k2) {
82755
+ if (k2 === void 0) k2 = k;
82756
+ o[k2] = m[k];
82757
+ }));
82758
+ var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? (function(o, v) {
82759
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
82760
+ }) : function(o, v) {
82761
+ o["default"] = v;
82762
+ });
82763
+ var __importStar = exports2 && exports2.__importStar || /* @__PURE__ */ (function() {
82764
+ var ownKeys = function(o) {
82765
+ ownKeys = Object.getOwnPropertyNames || function(o2) {
82766
+ var ar = [];
82767
+ for (var k in o2) if (Object.prototype.hasOwnProperty.call(o2, k)) ar[ar.length] = k;
82768
+ return ar;
82769
+ };
82770
+ return ownKeys(o);
82771
+ };
82772
+ return function(mod) {
82773
+ if (mod && mod.__esModule) return mod;
82774
+ var result = {};
82775
+ if (mod != null) {
82776
+ for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
82777
+ }
82778
+ __setModuleDefault(result, mod);
82779
+ return result;
82780
+ };
82781
+ })();
82782
+ var __importDefault = exports2 && exports2.__importDefault || function(mod) {
82783
+ return mod && mod.__esModule ? mod : { "default": mod };
82784
+ };
82785
+ Object.defineProperty(exports2, "__esModule", { value: true });
82786
+ exports2.parseMoney = parseMoney;
82787
+ exports2.parseLineItems = parseLineItems;
82788
+ exports2.nextInvoiceNumber = nextInvoiceNumber;
82789
+ exports2.invoiceDir = invoiceDir;
82790
+ exports2.createInvoice = createInvoice;
82791
+ exports2.getInvoiceByNumber = getInvoiceByNumber;
82792
+ exports2.listInvoices = listInvoices;
82793
+ exports2.markInvoicePaid = markInvoicePaid;
82794
+ exports2.renderInvoicePdf = renderInvoicePdf;
82795
+ exports2.renderLetterPdf = renderLetterPdf2;
82796
+ var pdfkit_1 = __importDefault(require("pdfkit"));
82797
+ var fs3 = __importStar(require("fs"));
82798
+ var path3 = __importStar(require("path"));
82799
+ var db_1 = require_db();
82800
+ var runtime_1 = require_runtime();
82801
+ var agency_1 = require_agency();
82802
+ function parseMoney(input) {
82803
+ const cleaned = String(input).trim().replace(/[£$€,\s]/g, "");
82804
+ if (!/^\d+(\.\d{1,2})?$/.test(cleaned)) {
82805
+ throw new Error(`"${input}" isn't a valid amount (expected e.g. 600 or 600.50).`);
82806
+ }
82807
+ const [whole, frac = ""] = cleaned.split(".");
82808
+ return Number(whole) * 100 + Number(frac.padEnd(2, "0"));
82809
+ }
82810
+ function parseLineItems(spec) {
82811
+ const items = [];
82812
+ for (const chunk of spec.split(",")) {
82813
+ const part = chunk.trim();
82814
+ if (!part)
82815
+ continue;
82816
+ const idx = part.lastIndexOf(":");
82817
+ if (idx === -1) {
82818
+ throw new Error(`"${part}" needs a price \u2014 use "Description:600".`);
82819
+ }
82820
+ const description = part.slice(0, idx).trim();
82821
+ const amount = part.slice(idx + 1).trim();
82822
+ if (!description)
82823
+ throw new Error(`Missing a description in "${part}".`);
82824
+ const qtyMatch = amount.match(/^(\d+)\s*[x*]\s*(.+)$/i);
82825
+ if (qtyMatch) {
82826
+ items.push({
82827
+ description,
82828
+ quantity: Number(qtyMatch[1]),
82829
+ unitPence: parseMoney(qtyMatch[2])
82830
+ });
82831
+ } else {
82832
+ items.push({ description, quantity: 1, unitPence: parseMoney(amount) });
82833
+ }
82834
+ }
82835
+ if (items.length === 0)
82836
+ throw new Error("No line items given.");
82837
+ return items;
82838
+ }
82839
+ function nextInvoiceNumber() {
82840
+ const year = (/* @__PURE__ */ new Date()).getFullYear();
82841
+ const prefix = `INV-${year}-`;
82842
+ const rows = (0, db_1.getDb)().all("SELECT number FROM invoices WHERE number LIKE ? ORDER BY number DESC LIMIT 1", [prefix + "%"]);
82843
+ const last = rows[0]?.number;
82844
+ const n = last ? Number(last.slice(prefix.length)) + 1 : 1;
82845
+ return prefix + String(n).padStart(3, "0");
82846
+ }
82847
+ function invoiceDir() {
82848
+ const dir = path3.join((0, runtime_1.userDataDir)(), "data", "invoices");
82849
+ fs3.mkdirSync(dir, { recursive: true });
82850
+ return dir;
82851
+ }
82852
+ function addDays(iso, days) {
82853
+ const d = /* @__PURE__ */ new Date(iso + "T00:00:00Z");
82854
+ d.setUTCDate(d.getUTCDate() + days);
82855
+ return d.toISOString().slice(0, 10);
82856
+ }
82857
+ function createInvoice(args) {
82858
+ const profile = (0, agency_1.getAgencyProfile)();
82859
+ const subtotal = args.items.reduce((sum, i) => sum + i.quantity * i.unitPence, 0);
82860
+ const rate = args.vatRate ?? profile.vatRate ?? 0;
82861
+ const vat = rate > 0 ? Math.round(subtotal * rate / 100) : 0;
82862
+ const total = subtotal + vat;
82863
+ const issued = args.issuedAt ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
82864
+ const number = nextInvoiceNumber();
82865
+ const db2 = (0, db_1.getDb)();
82866
+ db2.run(`INSERT INTO invoices
82867
+ (number, lead_id, client_name, client_email, client_address, line_items,
82868
+ subtotal_pence, vat_pence, total_pence, currency, status, issued_at, due_at, notes)
82869
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?)`, [
82870
+ number,
82871
+ args.leadId ?? null,
82872
+ args.clientName,
82873
+ args.clientEmail ?? null,
82874
+ args.clientAddress ?? null,
82875
+ JSON.stringify(args.items),
82876
+ subtotal,
82877
+ vat,
82878
+ total,
82879
+ profile.currency,
82880
+ issued,
82881
+ addDays(issued, args.dueInDays ?? 14),
82882
+ args.notes ?? null
82883
+ ]);
82884
+ return getInvoiceByNumber(number);
82885
+ }
82886
+ function getInvoiceByNumber(number) {
82887
+ return (0, db_1.getDb)().get("SELECT * FROM invoices WHERE number = ?", [number]) ?? null;
82888
+ }
82889
+ function listInvoices(filter) {
82890
+ let sql = "SELECT * FROM invoices";
82891
+ const conds = [];
82892
+ const params = [];
82893
+ if (filter?.status) {
82894
+ conds.push("status = ?");
82895
+ params.push(filter.status);
82896
+ }
82897
+ if (filter?.leadId) {
82898
+ conds.push("lead_id = ?");
82899
+ params.push(filter.leadId);
82900
+ }
82901
+ if (conds.length)
82902
+ sql += " WHERE " + conds.join(" AND ");
82903
+ sql += " ORDER BY id DESC";
82904
+ return (0, db_1.getDb)().all(sql, params);
82905
+ }
82906
+ function markInvoicePaid(number, when) {
82907
+ const inv = getInvoiceByNumber(number);
82908
+ if (!inv)
82909
+ throw new Error(`Invoice ${number} not found.`);
82910
+ (0, db_1.getDb)().run("UPDATE invoices SET status = 'paid', paid_at = ? WHERE number = ?", [
82911
+ when ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
82912
+ number
82913
+ ]);
82914
+ return getInvoiceByNumber(number);
82915
+ }
82916
+ var PAGE_MARGIN = 50;
82917
+ var INK = "#141412";
82918
+ var MUTED = "#6a6a5e";
82919
+ function hexOrDefault(hex, fallback) {
82920
+ return hex && /^#[0-9a-f]{6}$/i.test(hex) ? hex : fallback;
82921
+ }
82922
+ function drawLetterhead(doc, p) {
82923
+ const accent = hexOrDefault(p.accent, "#556B45");
82924
+ const right = doc.page.width - PAGE_MARGIN;
82925
+ let leftBottom = PAGE_MARGIN;
82926
+ if (p.logoPath && fs3.existsSync(p.logoPath)) {
82927
+ try {
82928
+ doc.image(p.logoPath, PAGE_MARGIN, PAGE_MARGIN, { fit: [150, 50] });
82929
+ leftBottom = PAGE_MARGIN + 50;
82930
+ } catch {
82931
+ doc.fillColor(INK).fontSize(20).font("Helvetica-Bold").text(p.name ?? "", PAGE_MARGIN, PAGE_MARGIN);
82932
+ leftBottom = doc.y;
82933
+ }
82934
+ } else {
82935
+ doc.fillColor(INK).fontSize(20).font("Helvetica-Bold").text(p.name ?? "", PAGE_MARGIN, PAGE_MARGIN);
82936
+ leftBottom = doc.y;
82937
+ }
82938
+ const details = [
82939
+ p.address,
82940
+ p.phone,
82941
+ p.email,
82942
+ p.website,
82943
+ p.companyNumber ? `Company no. ${p.companyNumber}` : null,
82944
+ p.vatNumber ? `VAT no. ${p.vatNumber}` : null
82945
+ ].filter(Boolean).join("\n");
82946
+ doc.fontSize(9).font("Helvetica").fillColor(MUTED);
82947
+ const w = 200;
82948
+ doc.text(details, right - w, PAGE_MARGIN, { width: w, align: "right" });
82949
+ const rightBottom = doc.y;
82950
+ const y = Math.max(leftBottom, rightBottom) + 18;
82951
+ doc.moveTo(PAGE_MARGIN, y).lineTo(right, y).strokeColor(accent).lineWidth(2).stroke();
82952
+ return y + 24;
82953
+ }
82954
+ function drawFooter(doc, p) {
82955
+ const y = doc.page.height - PAGE_MARGIN + 10;
82956
+ const bits = [p.name, p.email, p.phone].filter(Boolean).join(" \xB7 ");
82957
+ doc.fontSize(8).fillColor(MUTED).font("Helvetica").text(bits, PAGE_MARGIN, y, { width: doc.page.width - PAGE_MARGIN * 2, align: "center" });
82958
+ }
82959
+ async function renderInvoicePdf(invoice, outPath) {
82960
+ const p = (0, agency_1.getAgencyProfile)();
82961
+ if (!p.name) {
82962
+ throw new Error("No agency details set \u2014 run `sproutkit brand` first so invoices come from you, not from nobody.");
82963
+ }
82964
+ const accent = hexOrDefault(p.accent, "#556B45");
82965
+ const items = JSON.parse(invoice.line_items);
82966
+ const file = outPath ?? path3.join(invoiceDir(), `${invoice.number}.pdf`);
82967
+ const doc = new pdfkit_1.default({ size: "A4", margin: PAGE_MARGIN, info: {
82968
+ Title: `Invoice ${invoice.number}`,
82969
+ Author: p.name
82970
+ } });
82971
+ const stream = fs3.createWriteStream(file);
82972
+ doc.pipe(stream);
82973
+ let y = drawLetterhead(doc, p);
82974
+ const right = doc.page.width - PAGE_MARGIN;
82975
+ doc.fillColor(INK).font("Helvetica-Bold").fontSize(24).text("INVOICE", PAGE_MARGIN, y);
82976
+ doc.font("Helvetica").fontSize(10).fillColor(MUTED);
82977
+ const metaLines = [
82978
+ ["Invoice no.", invoice.number],
82979
+ ["Date", invoice.issued_at ?? ""],
82980
+ ["Due", invoice.due_at ?? ""]
82981
+ ];
82982
+ let metaY = y + 2;
82983
+ for (const [k, v] of metaLines) {
82984
+ doc.fillColor(MUTED).text(k, right - 200, metaY, { width: 100, align: "right" });
82985
+ doc.fillColor(INK).text(v, right - 95, metaY, { width: 95, align: "right" });
82986
+ metaY += 14;
82987
+ }
82988
+ y = Math.max(doc.y, metaY) + 18;
82989
+ doc.fillColor(MUTED).fontSize(9).font("Helvetica-Bold").text("BILL TO", PAGE_MARGIN, y);
82990
+ y = doc.y + 4;
82991
+ doc.fillColor(INK).fontSize(11).font("Helvetica").text(invoice.client_name, PAGE_MARGIN, y);
82992
+ y = doc.y;
82993
+ const clientMeta = [invoice.client_address, invoice.client_email].filter(Boolean).join("\n");
82994
+ if (clientMeta) {
82995
+ doc.fontSize(9).fillColor(MUTED).text(clientMeta, PAGE_MARGIN, y, { width: 250 });
82996
+ y = doc.y;
82997
+ }
82998
+ y += 22;
82999
+ const colDesc = PAGE_MARGIN;
83000
+ const colQty = right - 210;
83001
+ const colUnit = right - 150;
83002
+ const colAmt = right - 70;
83003
+ doc.fontSize(9).font("Helvetica-Bold").fillColor(MUTED);
83004
+ doc.text("DESCRIPTION", colDesc, y);
83005
+ doc.text("QTY", colQty, y, { width: 40, align: "right" });
83006
+ doc.text("UNIT", colUnit, y, { width: 70, align: "right" });
83007
+ doc.text("AMOUNT", colAmt, y, { width: 70, align: "right" });
83008
+ y = doc.y + 6;
83009
+ doc.moveTo(PAGE_MARGIN, y).lineTo(right, y).strokeColor("#e5e5dd").lineWidth(1).stroke();
83010
+ y += 10;
83011
+ doc.font("Helvetica").fontSize(10).fillColor(INK);
83012
+ for (const item of items) {
83013
+ const amount = item.quantity * item.unitPence;
83014
+ const descHeight = doc.heightOfString(item.description, { width: colQty - colDesc - 10 });
83015
+ doc.text(item.description, colDesc, y, { width: colQty - colDesc - 10 });
83016
+ doc.text(String(item.quantity), colQty, y, { width: 40, align: "right" });
83017
+ doc.text((0, agency_1.formatMoney)(item.unitPence, invoice.currency), colUnit, y, { width: 70, align: "right" });
83018
+ doc.text((0, agency_1.formatMoney)(amount, invoice.currency), colAmt, y, { width: 70, align: "right" });
83019
+ y += Math.max(descHeight, 14) + 8;
83020
+ if (y > doc.page.height - 200) {
83021
+ doc.addPage();
83022
+ y = PAGE_MARGIN;
83023
+ }
83024
+ }
83025
+ doc.moveTo(colQty, y).lineTo(right, y).strokeColor("#e5e5dd").stroke();
83026
+ y += 10;
83027
+ const totalRow = (label, value, bold = false) => {
83028
+ doc.font(bold ? "Helvetica-Bold" : "Helvetica").fontSize(bold ? 12 : 10).fillColor(bold ? INK : MUTED).text(label, colUnit - 60, y, { width: 130, align: "right" });
83029
+ doc.fillColor(INK).text(value, colAmt, y, { width: 70, align: "right" });
83030
+ y += bold ? 20 : 16;
83031
+ };
83032
+ totalRow("Subtotal", (0, agency_1.formatMoney)(invoice.subtotal_pence, invoice.currency));
83033
+ if (invoice.vat_pence > 0) {
83034
+ const rate = p.vatRate ?? 0;
83035
+ totalRow(`VAT (${rate}%)`, (0, agency_1.formatMoney)(invoice.vat_pence, invoice.currency));
83036
+ }
83037
+ doc.moveTo(colQty, y).lineTo(right, y).strokeColor(accent).lineWidth(2).stroke();
83038
+ y += 8;
83039
+ totalRow("Total due", (0, agency_1.formatMoney)(invoice.total_pence, invoice.currency), true);
83040
+ if (invoice.status === "paid") {
83041
+ doc.fontSize(11).font("Helvetica-Bold").fillColor(accent).text(`PAID${invoice.paid_at ? " \xB7 " + invoice.paid_at : ""}`, colUnit - 60, y, {
83042
+ width: 200,
83043
+ align: "right"
83044
+ });
83045
+ y = doc.y;
83046
+ }
83047
+ y += 24;
83048
+ const payBits = [
83049
+ p.bankName ? `Bank: ${p.bankName}` : null,
83050
+ p.bankAccount ? `Account: ${p.bankAccount}` : null,
83051
+ p.bankSortCode ? `Sort code: ${p.bankSortCode}` : null
83052
+ ].filter(Boolean);
83053
+ if (payBits.length) {
83054
+ doc.fontSize(9).font("Helvetica-Bold").fillColor(MUTED).text("PAYMENT", PAGE_MARGIN, y);
83055
+ y = doc.y + 4;
83056
+ doc.font("Helvetica").fontSize(10).fillColor(INK).text(payBits.join("\n"), PAGE_MARGIN, y);
83057
+ y = doc.y + 12;
83058
+ }
83059
+ const terms = invoice.notes || p.paymentTerms;
83060
+ if (terms) {
83061
+ doc.fontSize(9).font("Helvetica").fillColor(MUTED).text(terms, PAGE_MARGIN, y, {
83062
+ width: right - PAGE_MARGIN * 2
83063
+ });
83064
+ }
83065
+ drawFooter(doc, p);
83066
+ doc.end();
83067
+ await new Promise((resolve, reject) => {
83068
+ stream.on("finish", () => resolve());
83069
+ stream.on("error", reject);
83070
+ });
83071
+ (0, db_1.getDb)().run("UPDATE invoices SET pdf_path = ? WHERE number = ?", [file, invoice.number]);
83072
+ return file;
83073
+ }
83074
+ async function renderLetterPdf2(args) {
83075
+ const p = (0, agency_1.getAgencyProfile)();
83076
+ if (!p.name) {
83077
+ throw new Error("No agency details set \u2014 run `sproutkit brand` first.");
83078
+ }
83079
+ const dir = path3.join((0, runtime_1.userDataDir)(), "data", "letters");
83080
+ fs3.mkdirSync(dir, { recursive: true });
83081
+ const safe = args.to.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
83082
+ const file = args.outPath ?? path3.join(dir, `letter-${safe}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.pdf`);
83083
+ const doc = new pdfkit_1.default({ size: "A4", margin: PAGE_MARGIN, info: {
83084
+ Title: args.subject ?? `Letter to ${args.to}`,
83085
+ Author: p.name
83086
+ } });
83087
+ const stream = fs3.createWriteStream(file);
83088
+ doc.pipe(stream);
83089
+ let y = drawLetterhead(doc, p);
83090
+ const width = doc.page.width - PAGE_MARGIN * 2;
83091
+ doc.fontSize(10).fillColor(MUTED).font("Helvetica").text((/* @__PURE__ */ new Date()).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" }), PAGE_MARGIN, y);
83092
+ y = doc.y + 16;
83093
+ doc.fillColor(INK).fontSize(11).font("Helvetica").text(args.to, PAGE_MARGIN, y);
83094
+ y = doc.y;
83095
+ if (args.toAddress) {
83096
+ doc.fontSize(10).fillColor(MUTED).text(args.toAddress, PAGE_MARGIN, y, { width: 250 });
83097
+ y = doc.y;
83098
+ }
83099
+ y += 20;
83100
+ if (args.subject) {
83101
+ doc.fontSize(12).font("Helvetica-Bold").fillColor(INK).text(args.subject, PAGE_MARGIN, y, { width });
83102
+ y = doc.y + 14;
83103
+ }
83104
+ doc.fontSize(11).font("Helvetica").fillColor(INK);
83105
+ for (const para of args.body.split(/\n\s*\n/)) {
83106
+ doc.text(para.trim(), PAGE_MARGIN, y, { width, align: "left", lineGap: 3 });
83107
+ y = doc.y + 10;
83108
+ if (y > doc.page.height - 150) {
83109
+ doc.addPage();
83110
+ y = PAGE_MARGIN;
83111
+ }
83112
+ }
83113
+ y += 10;
83114
+ doc.text(args.signOff ?? `Kind regards,
83115
+ ${p.name}`, PAGE_MARGIN, y, { width });
83116
+ drawFooter(doc, p);
83117
+ doc.end();
83118
+ await new Promise((resolve, reject) => {
83119
+ stream.on("finish", () => resolve());
83120
+ stream.on("error", reject);
83121
+ });
83122
+ return file;
83123
+ }
83124
+ }
83125
+ });
83126
+
83127
+ // ../desktop/dist/main/lib/site-stats.js
83128
+ var require_site_stats = __commonJS({
83129
+ "../desktop/dist/main/lib/site-stats.js"(exports2) {
83130
+ "use strict";
83131
+ var __importDefault = exports2 && exports2.__importDefault || function(mod) {
83132
+ return mod && mod.__esModule ? mod : { "default": mod };
83133
+ };
83134
+ Object.defineProperty(exports2, "__esModule", { value: true });
83135
+ exports2.fetchSiteStats = fetchSiteStats;
83136
+ exports2.pctChange = pctChange;
83137
+ var axios_1 = __importDefault(require_axios());
83138
+ async function fetchSiteStats(siteUrl, adminPassword) {
83139
+ const base = siteUrl.replace(/\/$/, "");
83140
+ let cookie;
83141
+ try {
83142
+ const login = await axios_1.default.post(`${base}/api/admin/login`, { password: adminPassword }, {
83143
+ timeout: 2e4,
83144
+ headers: { "Content-Type": "application/json" },
83145
+ validateStatus: () => true
83146
+ });
83147
+ if (login.status === 429) {
83148
+ return {
83149
+ ok: false,
83150
+ error: "The site's login is rate limited right now \u2014 try again in a few minutes."
83151
+ };
83152
+ }
83153
+ if (login.status !== 200) {
83154
+ return {
83155
+ ok: false,
83156
+ error: login.status === 401 ? "The stored admin password was rejected \u2014 the client has probably changed it." : `Couldn't sign in to the site (${login.status}).`
83157
+ };
83158
+ }
83159
+ const setCookie = login.headers["set-cookie"];
83160
+ const raw = Array.isArray(setCookie) ? setCookie[0] : setCookie;
83161
+ if (!raw)
83162
+ return { ok: false, error: "The site didn't return a session cookie." };
83163
+ cookie = String(raw).split(";")[0];
83164
+ } catch (e) {
83165
+ return { ok: false, error: `Couldn't reach the site: ${e.message}` };
83166
+ }
83167
+ try {
83168
+ const res = await axios_1.default.get(`${base}/api/stats`, {
83169
+ timeout: 2e4,
83170
+ headers: { Cookie: cookie },
83171
+ validateStatus: () => true
83172
+ });
83173
+ if (res.status === 404) {
83174
+ return {
83175
+ ok: false,
83176
+ error: "This site was built before analytics shipped. Run `sproutkit regenerate` on it to add them."
83177
+ };
83178
+ }
83179
+ if (res.status !== 200)
83180
+ return { ok: false, error: `Stats unavailable (${res.status}).` };
83181
+ return { ok: true, data: res.data };
83182
+ } catch (e) {
83183
+ return { ok: false, error: `Couldn't read stats: ${e.message}` };
83184
+ }
83185
+ }
83186
+ function pctChange(now, before) {
83187
+ if (!before)
83188
+ return now > 0 ? null : 0;
83189
+ return Math.round((now - before) / before * 100);
83190
+ }
83191
+ }
83192
+ });
83193
+
82238
83194
  // ../../node_modules/nodemailer/lib/fetch/cookies.js
82239
83195
  var require_cookies2 = __commonJS({
82240
83196
  "../../node_modules/nodemailer/lib/fetch/cookies.js"(exports2, module2) {
@@ -94014,6 +94970,10 @@ var require_chat_tools = __commonJS({
94014
94970
  var lead_search_1 = require_lead_search();
94015
94971
  var places_1 = require_places();
94016
94972
  var booking_detect_1 = require_booking_detect();
94973
+ var followup_1 = require_followup();
94974
+ var invoicing_1 = require_invoicing();
94975
+ var agency_1 = require_agency();
94976
+ var site_stats_1 = require_site_stats();
94017
94977
  var email_1 = require_email();
94018
94978
  var email_harvest_1 = require_email_harvest();
94019
94979
  exports2.TOOLS = [
@@ -94128,6 +95088,79 @@ var require_chat_tools = __commonJS({
94128
95088
  },
94129
95089
  requiresConfirmation: true
94130
95090
  },
95091
+ {
95092
+ name: "log_reply",
95093
+ description: "Record how a business responded to outreach. SMS/WhatsApp replies land on the operator's phone and are invisible to Sproutkit, so this is how a reply enters the pipeline. Outcomes: 'interested' or 'callback' move the lead to Replied; 'not_interested' and 'wrong_number' move it to Dead; 'no_answer' just records the attempt without changing the stage.",
95094
+ input_schema: {
95095
+ type: "object",
95096
+ properties: {
95097
+ id: { type: "number", description: "Lead id that replied." },
95098
+ outcome: {
95099
+ type: "string",
95100
+ enum: ["interested", "not_interested", "no_answer", "wrong_number", "callback"],
95101
+ description: "What happened."
95102
+ },
95103
+ note: { type: "string", description: "Optional free-text note, e.g. 'wants to see menu options first'." }
95104
+ },
95105
+ required: ["id", "outcome"]
95106
+ }
95107
+ },
95108
+ {
95109
+ name: "list_followups",
95110
+ description: "Leads that were contacted a while ago and have never replied \u2014 who to chase. Optionally set afterDays (default 3). Also returns outreach funnel stats (contacted, replied, reply rate).",
95111
+ input_schema: {
95112
+ type: "object",
95113
+ properties: {
95114
+ afterDays: { type: "number", description: "Only include leads contacted at least this many days ago. Default 3." }
95115
+ }
95116
+ }
95117
+ },
95118
+ {
95119
+ name: "create_invoice",
95120
+ description: "Create an invoice for a client and render it as a PDF on the agency's letterhead. Items are given as 'Description:amount' pairs separated by commas, e.g. 'Website build:1200,Booking system:450' \u2014 or with a quantity, 'Monthly hosting:12x25'. Requires the agency profile to be set up (sproutkit brand). Returns the invoice number, totals and PDF path.",
95121
+ input_schema: {
95122
+ type: "object",
95123
+ properties: {
95124
+ id: { type: "number", description: "Lead id to invoice (fills in name/email/address)." },
95125
+ clientName: { type: "string", description: "Client name, if not invoicing an existing lead." },
95126
+ items: { type: "string", description: "'Website build:1200,Booking system:450' or 'Hosting:12x25'." },
95127
+ dueInDays: { type: "number", description: "Payment terms in days. Default 14." },
95128
+ notes: { type: "string", description: "Optional note printed on the invoice." }
95129
+ },
95130
+ required: ["items"]
95131
+ },
95132
+ requiresConfirmation: true
95133
+ },
95134
+ {
95135
+ name: "list_invoices",
95136
+ description: "List invoices, optionally filtered by status ('issued' or 'paid') or lead id. Shows totals and what's outstanding.",
95137
+ input_schema: {
95138
+ type: "object",
95139
+ properties: {
95140
+ status: { type: "string", enum: ["issued", "paid"], description: "Filter by status." },
95141
+ id: { type: "number", description: "Only invoices for this lead." }
95142
+ }
95143
+ }
95144
+ },
95145
+ {
95146
+ name: "mark_invoice_paid",
95147
+ description: "Mark an invoice as paid by its number, e.g. INV-2026-001.",
95148
+ input_schema: {
95149
+ type: "object",
95150
+ properties: { number: { type: "string", description: "Invoice number." } },
95151
+ required: ["number"]
95152
+ },
95153
+ requiresConfirmation: true
95154
+ },
95155
+ {
95156
+ name: "site_report",
95157
+ description: "Read a client site's visitor numbers \u2014 calls, booking clicks, page views for the last 30 days versus the previous 30. This is the renewal/retainer evidence. Requires the site to have been deployed with analytics (rebuild an older site to add them).",
95158
+ input_schema: {
95159
+ type: "object",
95160
+ properties: { id: { type: "number", description: "Lead id." } },
95161
+ required: ["id"]
95162
+ }
95163
+ },
94131
95164
  {
94132
95165
  name: "hand_over_site",
94133
95166
  description: "HAND OVER a finished site to the paying client: removes the DEMO/preview banner, optionally attaches the client's own domain, and returns every credential in one place (site URL, /admin panel + password, booking page + its password, and the DNS records the client must set at their registrar). This is the paid-deliverable step \u2014 the banner exists to stop clients using a free demo as their real site, so only run this when the user says the client has paid / signed off / is going live. Requires the lead to already have a built site or booking page.",
@@ -94607,6 +95640,11 @@ Interested?`;
94607
95640
  (/* @__PURE__ */ new Date()).toISOString(),
94608
95641
  r.id
94609
95642
  ]);
95643
+ (0, followup_1.logContact)({
95644
+ leadId: r.id,
95645
+ direction: "out",
95646
+ channel: e164 ? channel : "email"
95647
+ });
94610
95648
  }
94611
95649
  }
94612
95650
  ctx.emitProgress?.({ channel: "leads:changed", payload: { reason: "prepare_outreach" } });
@@ -94803,6 +95841,116 @@ Interested?`;
94803
95841
  });
94804
95842
  return { ok: true, id, ...result };
94805
95843
  }
95844
+ case "log_reply": {
95845
+ const id = Number(input.id);
95846
+ const outcome = String(input.outcome);
95847
+ const lead = db2.get("SELECT id, name, status FROM leads WHERE id = ?", [id]);
95848
+ if (!lead)
95849
+ throw new Error(`Lead #${id} not found`);
95850
+ (0, followup_1.logContact)({
95851
+ leadId: id,
95852
+ direction: "in",
95853
+ outcome,
95854
+ note: input.note ?? null
95855
+ });
95856
+ const after = db2.get("SELECT status FROM leads WHERE id = ?", [id]);
95857
+ ctx.emitProgress?.({ channel: "leads:changed", payload: { reason: "log_reply" } });
95858
+ return { ok: true, id, business: lead.name, outcome, status: after.status };
95859
+ }
95860
+ case "list_followups": {
95861
+ const afterDays = Number(input.afterDays ?? 3);
95862
+ return { ok: true, afterDays, due: (0, followup_1.dueFollowUps)(afterDays), stats: (0, followup_1.outreachStats)() };
95863
+ }
95864
+ case "create_invoice": {
95865
+ const items = (0, invoicing_1.parseLineItems)(String(input.items));
95866
+ let leadId = null;
95867
+ let clientName = input.clientName ?? "";
95868
+ let clientEmail = null;
95869
+ let clientAddress = null;
95870
+ if (input.id != null) {
95871
+ leadId = Number(input.id);
95872
+ const lead = db2.get("SELECT id, name, email, owner_email, address FROM leads WHERE id = ?", [leadId]);
95873
+ if (!lead)
95874
+ throw new Error(`Lead #${leadId} not found`);
95875
+ clientName = clientName || lead.name;
95876
+ clientEmail = lead.owner_email || lead.email;
95877
+ clientAddress = lead.address;
95878
+ }
95879
+ if (!clientName) {
95880
+ return { ok: false, error: "Who is this invoice for? Give a lead id or a clientName." };
95881
+ }
95882
+ const invoice = (0, invoicing_1.createInvoice)({
95883
+ leadId,
95884
+ clientName,
95885
+ clientEmail,
95886
+ clientAddress,
95887
+ items,
95888
+ dueInDays: input.dueInDays != null ? Number(input.dueInDays) : void 0,
95889
+ notes: input.notes ?? null
95890
+ });
95891
+ const pdf = await (0, invoicing_1.renderInvoicePdf)(invoice);
95892
+ return {
95893
+ ok: true,
95894
+ number: invoice.number,
95895
+ client: invoice.client_name,
95896
+ subtotal: (0, agency_1.formatMoney)(invoice.subtotal_pence, invoice.currency),
95897
+ vat: invoice.vat_pence ? (0, agency_1.formatMoney)(invoice.vat_pence, invoice.currency) : null,
95898
+ total: (0, agency_1.formatMoney)(invoice.total_pence, invoice.currency),
95899
+ due: invoice.due_at,
95900
+ pdf
95901
+ };
95902
+ }
95903
+ case "list_invoices": {
95904
+ const rows = (0, invoicing_1.listInvoices)({
95905
+ status: input.status,
95906
+ leadId: input.id != null ? Number(input.id) : void 0
95907
+ });
95908
+ const outstanding = rows.filter((r) => r.status !== "paid").reduce((sum, r) => sum + r.total_pence, 0);
95909
+ return {
95910
+ ok: true,
95911
+ invoices: rows.map((r) => ({
95912
+ number: r.number,
95913
+ client: r.client_name,
95914
+ total: (0, agency_1.formatMoney)(r.total_pence, r.currency),
95915
+ status: r.status,
95916
+ issued: r.issued_at,
95917
+ due: r.due_at,
95918
+ paid: r.paid_at,
95919
+ pdf: r.pdf_path
95920
+ })),
95921
+ outstanding: (0, agency_1.formatMoney)(outstanding)
95922
+ };
95923
+ }
95924
+ case "mark_invoice_paid": {
95925
+ const invoice = (0, invoicing_1.markInvoicePaid)(String(input.number));
95926
+ return {
95927
+ ok: true,
95928
+ number: invoice.number,
95929
+ client: invoice.client_name,
95930
+ total: (0, agency_1.formatMoney)(invoice.total_pence, invoice.currency),
95931
+ paid: invoice.paid_at
95932
+ };
95933
+ }
95934
+ case "site_report": {
95935
+ const id = Number(input.id);
95936
+ const lead = db2.get("SELECT id, name, site_url, booking_url, admin_url, admin_password, booking_admin_password FROM leads WHERE id = ?", [id]);
95937
+ if (!lead)
95938
+ throw new Error(`Lead #${id} not found`);
95939
+ const base = lead.site_url || lead.booking_url;
95940
+ if (!base)
95941
+ return { ok: false, error: `#${id} ${lead.name} has no live site yet.` };
95942
+ const password = lead.admin_password || lead.booking_admin_password;
95943
+ if (!password) {
95944
+ return {
95945
+ ok: false,
95946
+ error: `No admin password stored for #${id}, so I can't read its stats. Rebuild or hand over the site to provision one.`
95947
+ };
95948
+ }
95949
+ const stats = await (0, site_stats_1.fetchSiteStats)(base, password);
95950
+ if (!stats.ok)
95951
+ return { ok: false, error: stats.error, business: lead.name };
95952
+ return { ok: true, id, business: lead.name, url: base, ...stats.data };
95953
+ }
94806
95954
  case "hand_over_site": {
94807
95955
  const id = Number(input.id);
94808
95956
  const lead = db2.get("SELECT id, name, site_url, booking_url FROM leads WHERE id = ?", [id]);
@@ -95836,7 +96984,7 @@ var require_package2 = __commonJS({
95836
96984
  "package.json"(exports2, module2) {
95837
96985
  module2.exports = {
95838
96986
  name: "sproutkit-code",
95839
- version: "1.5.1",
96987
+ version: "1.6.0",
95840
96988
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
95841
96989
  bin: {
95842
96990
  sproutkit: "./dist/index.js"
@@ -95865,7 +97013,8 @@ var require_package2 = __commonJS({
95865
97013
  dependencies: {
95866
97014
  "@anthropic-ai/sdk": "^0.30.1",
95867
97015
  axios: "^1.7.1",
95868
- "node-sqlite3-wasm": "^0.8.20"
97016
+ "node-sqlite3-wasm": "^0.8.20",
97017
+ pdfkit: "^0.20.1"
95869
97018
  },
95870
97019
  devDependencies: {
95871
97020
  "@types/node": "^20.14.0",
@@ -95884,6 +97033,9 @@ var cms = require_cms_provision();
95884
97033
  var bookingPreset = require_booking_preset();
95885
97034
  var builder = require_builder();
95886
97035
  var vercelStore = require_vercel_store();
97036
+ var agency = require_agency();
97037
+ var followup = require_followup();
97038
+ var invoicing = require_invoicing();
95887
97039
  var models = require_models();
95888
97040
  var llm = require_llm();
95889
97041
  var initDatabase = db.initDatabase;
@@ -95902,6 +97054,15 @@ var refreshModels = models.refreshModels;
95902
97054
  var refreshIfStale = models.refreshIfStale;
95903
97055
  var listCachedModels = models.listCachedModels;
95904
97056
  var getPreferredModel = models.getPreferredModel;
97057
+ var getAgencyProfile = agency.getAgencyProfile;
97058
+ var setAgencyProfile = agency.setAgencyProfile;
97059
+ var agencyConfigured = agency.agencyConfigured;
97060
+ var formatMoney = agency.formatMoney;
97061
+ var dueFollowUps = followup.dueFollowUps;
97062
+ var outreachStats = followup.outreachStats;
97063
+ var contactHistory = followup.contactHistory;
97064
+ var renderLetterPdf = invoicing.renderLetterPdf;
97065
+ var listInvoicesRaw = invoicing.listInvoices;
95905
97066
  var checkClaudeCodeAuth = llm.checkClaudeCodeAuth;
95906
97067
  var llmBackend = llm.getBackend;
95907
97068
  var claudeBin = llm.claudeBin;
@@ -97331,6 +98492,370 @@ async function cmdHandover(argv) {
97331
98492
  process.exitCode = 1;
97332
98493
  }
97333
98494
  }
98495
+ async function cmdBrand(argv) {
98496
+ const { flags } = parseArgs(argv);
98497
+ const json = !!flags.json;
98498
+ const FIELDS = [
98499
+ ["name", "Agency / your trading name"],
98500
+ ["email", "Contact email"],
98501
+ ["phone", "Contact phone"],
98502
+ ["website", "Website"],
98503
+ ["address", "Address (use \\n between lines)"],
98504
+ ["company-number", "Company number"],
98505
+ ["vat-number", "VAT number"],
98506
+ ["vat-rate", "VAT rate % (blank if not registered)"],
98507
+ ["bank-name", "Bank name"],
98508
+ ["bank-account", "Account number"],
98509
+ ["bank-sort-code", "Sort code"],
98510
+ ["payment-terms", "Payment terms line"],
98511
+ ["logo", "Path to a logo image (png/jpg)"],
98512
+ ["accent", "Accent colour hex, e.g. #556B45"]
98513
+ ];
98514
+ const toKey = (flag) => ({
98515
+ "company-number": "companyNumber",
98516
+ "vat-number": "vatNumber",
98517
+ "vat-rate": "vatRate",
98518
+ "bank-name": "bankName",
98519
+ "bank-account": "bankAccount",
98520
+ "bank-sort-code": "bankSortCode",
98521
+ "payment-terms": "paymentTerms",
98522
+ logo: "logoPath"
98523
+ })[flag] ?? flag;
98524
+ const current = getAgencyProfile();
98525
+ if (flags.show || argv.length === 0 || argv.length === 1 && json) {
98526
+ if (json) {
98527
+ emitJson({ ok: true, profile: current });
98528
+ return;
98529
+ }
98530
+ if (!current.name) {
98531
+ say();
98532
+ say(c.amber(" No agency details set yet."));
98533
+ say(c.dim(" These go on your invoices, letters and the sites you deliver."));
98534
+ say(c.dim(' Set them with: sproutkit brand --name "Northgate Web Studio" --email you@x.com'));
98535
+ say(c.dim(" Or run: sproutkit brand --interactive"));
98536
+ say();
98537
+ return;
98538
+ }
98539
+ say();
98540
+ say(c.bold(" Your agency details"));
98541
+ for (const [flag, label] of FIELDS) {
98542
+ const v = current[toKey(flag)];
98543
+ if (v) say(` ${label.padEnd(34)} ${String(v).replace(/\n/g, ", ")}`);
98544
+ }
98545
+ say();
98546
+ say(c.dim(' Change any of these with: sproutkit brand --<field> "value"'));
98547
+ say();
98548
+ return;
98549
+ }
98550
+ if (flags.interactive) {
98551
+ say();
98552
+ say(c.bold(" Your agency details"));
98553
+ say(c.dim(" These appear on invoices, letters and the sites you hand over."));
98554
+ say(c.dim(" Press enter to keep what's there / skip."));
98555
+ say();
98556
+ const patch2 = {};
98557
+ for (const [flag, label] of FIELDS) {
98558
+ const key = toKey(flag);
98559
+ const existing = current[key];
98560
+ const shown = existing ? c.dim(` [${String(existing).replace(/\n/g, ", ")}]`) : "";
98561
+ const answer = (await ask(` ${label}${shown}: `)).trim();
98562
+ if (!answer) continue;
98563
+ patch2[key] = key === "vatRate" ? Number(answer) || null : answer.replace(/\\n/g, "\n");
98564
+ }
98565
+ const saved2 = setAgencyProfile(patch2);
98566
+ say();
98567
+ say(c.green(` \u2713 Saved \u2014 invoices and sites will now say "${String(saved2.name ?? "")}"`));
98568
+ say();
98569
+ return;
98570
+ }
98571
+ const patch = {};
98572
+ for (const [flag] of FIELDS) {
98573
+ if (flags[flag] === void 0) continue;
98574
+ const key = toKey(flag);
98575
+ const raw = String(flags[flag]);
98576
+ patch[key] = key === "vatRate" ? Number(raw) || null : raw.replace(/\\n/g, "\n");
98577
+ }
98578
+ if (Object.keys(patch).length === 0) {
98579
+ const err = 'Nothing to set. Try: sproutkit brand --name "Your Studio" --email you@x.com, or --show';
98580
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98581
+ process.exitCode = 1;
98582
+ return;
98583
+ }
98584
+ const saved = setAgencyProfile(patch);
98585
+ if (json) {
98586
+ emitJson({ ok: true, profile: saved });
98587
+ return;
98588
+ }
98589
+ say();
98590
+ say(c.green(` \u2713 Updated ${Object.keys(patch).length} field(s).`));
98591
+ if (!saved.name) say(c.amber(" Still missing a name \u2014 invoices need one."));
98592
+ say();
98593
+ }
98594
+ async function cmdInvoice(argv) {
98595
+ const { flags, rest } = parseArgs(argv);
98596
+ const json = !!flags.json;
98597
+ const sub = rest[0];
98598
+ if (sub === "list") {
98599
+ const r = await runTool(
98600
+ "list_invoices",
98601
+ { status: flags.unpaid ? "issued" : flags.status },
98602
+ makeCtx({ json })
98603
+ );
98604
+ if (json) {
98605
+ emitJson(r);
98606
+ return;
98607
+ }
98608
+ if (!r.invoices.length) {
98609
+ say(c.dim(' No invoices yet. Create one: sproutkit invoice <leadId> --items "Website:1200"'));
98610
+ return;
98611
+ }
98612
+ say();
98613
+ say(" " + c.dim("NUMBER".padEnd(15) + "CLIENT".padEnd(26) + "TOTAL".padEnd(11) + "DUE".padEnd(12) + "STATUS"));
98614
+ say(" " + c.dim("\u2500".repeat(72)));
98615
+ for (const i of r.invoices) {
98616
+ const paid = i.status === "paid";
98617
+ say(
98618
+ " " + c.bold(i.number.padEnd(15)) + i.client.slice(0, 24).padEnd(26) + i.total.padEnd(11) + c.dim((i.due ?? "").padEnd(12)) + (paid ? c.green("paid") : c.amber("unpaid"))
98619
+ );
98620
+ }
98621
+ say();
98622
+ say(" " + c.dim("Outstanding: ") + c.bold(r.outstanding));
98623
+ say();
98624
+ return;
98625
+ }
98626
+ if (sub === "paid") {
98627
+ const number = rest[1] ?? flags.number;
98628
+ if (!number) {
98629
+ const err = "Usage: sproutkit invoice paid INV-2026-001";
98630
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98631
+ process.exitCode = 1;
98632
+ return;
98633
+ }
98634
+ const r = await runTool("mark_invoice_paid", { number }, makeCtx({ json }));
98635
+ json ? emitJson(r) : say(c.green(` \u2713 ${r.number} marked paid \u2014 ${r.client}, ${r.total}`));
98636
+ return;
98637
+ }
98638
+ const id = Number(flags.id ?? rest[0]);
98639
+ const items = flags.items ? String(flags.items) : "";
98640
+ if (!items) {
98641
+ const err = 'Usage: sproutkit invoice <leadId> --items "Website build:1200,Booking system:450"\n quantities: --items "Monthly hosting:12x25" \xB7 also: invoice list | invoice paid <number>';
98642
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98643
+ process.exitCode = 1;
98644
+ return;
98645
+ }
98646
+ const status = json ? void 0 : new Status();
98647
+ if (status) status.start("Creating invoice");
98648
+ try {
98649
+ const r = await runTool(
98650
+ "create_invoice",
98651
+ {
98652
+ id: Number.isNaN(id) ? void 0 : id,
98653
+ clientName: flags.client ? String(flags.client) : void 0,
98654
+ items,
98655
+ dueInDays: flags.due ? Number(flags.due) : void 0,
98656
+ notes: flags.notes ? String(flags.notes) : void 0
98657
+ },
98658
+ makeCtx({ json, status })
98659
+ );
98660
+ if (r && r.ok === false) {
98661
+ if (status) status.done(false, r.error ?? "Couldn't create the invoice");
98662
+ if (json) emitJson(r);
98663
+ process.exitCode = 1;
98664
+ return;
98665
+ }
98666
+ if (status) status.done(true, `${r.number} \u2014 ${r.total}`);
98667
+ if (json) {
98668
+ emitJson(r);
98669
+ return;
98670
+ }
98671
+ say();
98672
+ say(` ${c.bold(String(r.number))} ${r.client}`);
98673
+ say(c.dim(` Subtotal ${r.subtotal}`) + (r.vat ? c.dim(` \xB7 VAT ${r.vat}`) : ""));
98674
+ say(` ${c.bold("Total " + r.total)}` + c.dim(` \xB7 due ${r.due}`));
98675
+ say(c.dim(` PDF: ${r.pdf}`));
98676
+ say();
98677
+ } catch (e) {
98678
+ if (status) status.done(false, "Invoice failed");
98679
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
98680
+ process.exitCode = 1;
98681
+ }
98682
+ }
98683
+ async function cmdLetter(argv) {
98684
+ const { flags, rest } = parseArgs(argv);
98685
+ const json = !!flags.json;
98686
+ const id = Number(flags.id ?? rest[0]);
98687
+ let body = flags.body ? String(flags.body) : "";
98688
+ if (!body && flags["body-file"]) {
98689
+ try {
98690
+ body = require("fs").readFileSync(String(flags["body-file"]), "utf8");
98691
+ } catch (e) {
98692
+ const err = `Couldn't read ${flags["body-file"]}: ${e.message}`;
98693
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98694
+ process.exitCode = 1;
98695
+ return;
98696
+ }
98697
+ }
98698
+ const to = flags.to ? String(flags.to) : "";
98699
+ if (!body || !to && Number.isNaN(id)) {
98700
+ const err = 'Usage: sproutkit letter <leadId> --subject "Your new website" --body "text"\n or: sproutkit letter --to "Name" --body-file letter.txt';
98701
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98702
+ process.exitCode = 1;
98703
+ return;
98704
+ }
98705
+ let recipient = to;
98706
+ let address = null;
98707
+ if (!recipient && !Number.isNaN(id)) {
98708
+ const lead = await runTool("get_lead", { id }, makeCtx({ json: true }));
98709
+ if (!lead?.name) {
98710
+ const err = `Lead #${id} not found`;
98711
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98712
+ process.exitCode = 1;
98713
+ return;
98714
+ }
98715
+ recipient = lead.name;
98716
+ address = lead.address ?? null;
98717
+ }
98718
+ try {
98719
+ const file = await renderLetterPdf({
98720
+ to: recipient,
98721
+ toAddress: address,
98722
+ subject: flags.subject ? String(flags.subject) : null,
98723
+ body
98724
+ });
98725
+ json ? emitJson({ ok: true, to: recipient, pdf: file }) : say(c.green(` \u2713 Letter for ${recipient}
98726
+ ${file}`));
98727
+ } catch (e) {
98728
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
98729
+ process.exitCode = 1;
98730
+ }
98731
+ }
98732
+ async function cmdReply(argv) {
98733
+ const { flags, rest } = parseArgs(argv);
98734
+ const json = !!flags.json;
98735
+ const id = Number(flags.id ?? rest[0]);
98736
+ const outcome = flags.interested ? "interested" : flags["not-interested"] ? "not_interested" : flags["no-answer"] ? "no_answer" : flags["wrong-number"] ? "wrong_number" : flags.callback ? "callback" : flags.outcome;
98737
+ if (!id || Number.isNaN(id) || !outcome) {
98738
+ const err = 'Usage: sproutkit reply <leadId> --interested | --not-interested | --no-answer | --wrong-number | --callback\n optional: --note "wants to see menu options first"';
98739
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98740
+ process.exitCode = 1;
98741
+ return;
98742
+ }
98743
+ try {
98744
+ const r = await runTool(
98745
+ "log_reply",
98746
+ { id, outcome, note: flags.note ? String(flags.note) : void 0 },
98747
+ makeCtx({ json })
98748
+ );
98749
+ autoSync();
98750
+ if (json) {
98751
+ emitJson(r);
98752
+ return;
98753
+ }
98754
+ const nice = {
98755
+ interested: "interested",
98756
+ callback: "wants a callback",
98757
+ not_interested: "not interested",
98758
+ no_answer: "no answer",
98759
+ wrong_number: "wrong number"
98760
+ };
98761
+ say(c.green(` \u2713 ${r.business} \u2014 ${nice[String(r.outcome)] ?? r.outcome}`) + c.dim(` (now ${r.status})`));
98762
+ } catch (e) {
98763
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
98764
+ process.exitCode = 1;
98765
+ }
98766
+ }
98767
+ async function cmdFollowups(argv) {
98768
+ const { flags } = parseArgs(argv);
98769
+ const json = !!flags.json;
98770
+ const afterDays = flags.days ? Number(flags.days) : 3;
98771
+ const r = await runTool("list_followups", { afterDays }, makeCtx({ json }));
98772
+ if (json) {
98773
+ emitJson(r);
98774
+ return;
98775
+ }
98776
+ const s = r.stats;
98777
+ say();
98778
+ if (!r.due.length) {
98779
+ say(c.dim(` Nobody to chase \u2014 no unanswered outreach older than ${afterDays} day(s).`));
98780
+ } else {
98781
+ say(" " + c.dim("ID".padEnd(6) + "BUSINESS".padEnd(30) + "WAITING".padEnd(10) + "REACH THEM ON"));
98782
+ say(" " + c.dim("\u2500".repeat(74)));
98783
+ for (const l of r.due) {
98784
+ const reach = l.phone || l.email || c.dim("no contact");
98785
+ say(
98786
+ " " + c.dim(("#" + l.id).padEnd(6)) + c.bold(l.name.slice(0, 28).padEnd(30)) + c.amber((l.days_since + "d").padEnd(10)) + reach
98787
+ );
98788
+ }
98789
+ say();
98790
+ say(c.dim(" Log what happens with: sproutkit reply <id> --interested|--not-interested|--no-answer"));
98791
+ }
98792
+ say();
98793
+ say(
98794
+ " " + c.dim("Funnel: ") + `${s.contacted} contacted \xB7 ${s.replied} replied ` + c.dim(`(${s.replyRatePct}%)`) + ` \xB7 ${s.interested} interested \xB7 ${s.won} won` + c.dim(` \xB7 ${s.awaitingReply} awaiting reply`)
98795
+ );
98796
+ say();
98797
+ }
98798
+ async function cmdReport(argv) {
98799
+ const { flags, rest } = parseArgs(argv);
98800
+ const json = !!flags.json;
98801
+ const id = Number(flags.id ?? rest[0]);
98802
+ if (!id || Number.isNaN(id)) {
98803
+ const err = "Usage: sproutkit report <leadId>";
98804
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98805
+ process.exitCode = 1;
98806
+ return;
98807
+ }
98808
+ const status = json ? void 0 : new Status();
98809
+ if (status) status.start(`Reading numbers for #${id}`);
98810
+ try {
98811
+ const r = await runTool("site_report", { id }, makeCtx({ json, status }));
98812
+ if (r && r.ok === false) {
98813
+ if (status) status.done(false, r.error ?? "No stats");
98814
+ if (json) emitJson(r);
98815
+ process.exitCode = 1;
98816
+ return;
98817
+ }
98818
+ if (status) status.done(true, `${r.business}`);
98819
+ if (json) {
98820
+ emitJson(r);
98821
+ return;
98822
+ }
98823
+ const LABEL = {
98824
+ call: "Phone calls",
98825
+ book: "Booking clicks",
98826
+ view: "Page views",
98827
+ directions: "Directions",
98828
+ email: "Email clicks",
98829
+ menu: "Menu views"
98830
+ };
98831
+ say();
98832
+ say(` ${c.bold(String(r.business))}` + c.dim(` \xB7 ${r.url}`));
98833
+ say(c.dim(" Last 30 days vs the 30 before"));
98834
+ say();
98835
+ for (const k of ["call", "book", "view", "directions", "email", "menu"]) {
98836
+ const now = r.last30?.[k] ?? 0;
98837
+ const before = r.prev30?.[k] ?? 0;
98838
+ if (!now && !before) continue;
98839
+ let delta = "";
98840
+ if (before) {
98841
+ const pct = Math.round((now - before) / before * 100);
98842
+ delta = pct === 0 ? c.dim(" no change") : (pct > 0 ? c.green : c.amber)(` ${pct > 0 ? "+" : ""}${pct}%`);
98843
+ } else if (now) {
98844
+ delta = c.green(" new");
98845
+ }
98846
+ say(` ${LABEL[k].padEnd(16)} ${c.bold(String(now).padStart(5))}${delta}`);
98847
+ }
98848
+ const total = Object.values(r.allTime ?? {}).reduce((a, b) => a + b, 0);
98849
+ if (!total) {
98850
+ say(c.dim(" Nothing recorded yet \u2014 either it's very new, or it was built before analytics shipped."));
98851
+ }
98852
+ say();
98853
+ } catch (e) {
98854
+ if (status) status.done(false, "Report failed");
98855
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
98856
+ process.exitCode = 1;
98857
+ }
98858
+ }
97334
98859
  async function cmdRegenerate(argv) {
97335
98860
  const { flags, rest } = parseArgs(argv);
97336
98861
  const json = !!flags.json;
@@ -97563,18 +99088,26 @@ var TASK_SYSTEM_PROMPT = `You operate the "sproutkit" CLI on the user's behalf.
97563
99088
  HOW TO ACT \u2014 this is strict:
97564
99089
  - Fulfil each request by running the matching sproutkit command(s) via Bash. Nothing else.
97565
99090
  - Do NOT read, list, or explore the repo. Do NOT narrate a plan, think out loud, or look for a skill. No preamble, no "let me\u2026". Either ask a crisp question (below) or run the command.
97566
- - Always pass --json and parse it. Get lead ids from \`sproutkit list --json\` \u2014 never guess an id.
99091
+ - Pass --json for commands you need to READ data from (to get ids, urls, credentials).
99092
+ - EXCEPTION \u2014 \`sproutkit list\`: run it WITHOUT --json and print its output verbatim. It
99093
+ already prints a formatted table with links and a stage tally. Never re-render it yourself,
99094
+ never reformat it, never summarise it, never truncate it. Use --json for list ONLY when you
99095
+ need a field to feed into another command, and in that case don't display the JSON.
97567
99096
 
97568
99097
  OUTPUT FORMAT \u2014 terse, scannable, NO editorialising:
97569
99098
  - Never write rambling sentences or commentary about the results ("well short of 20, so the source is returning few matches" \u2014 DON'T). Just present the data.
97570
99099
  - For find: one line "Found N (M new)", then one line per NEW lead: "#<id> <name> \xB7 <type>".
97571
- - For list: one line per lead "#<id> <name> \xB7 <type> \xB7 <city> \xB7 <status>" (cap at ~15, then "\u2026and K more").
99100
+ - For list: print the command's own table output EXACTLY as it comes back. It is already
99101
+ formatted and aligned. Do not rebuild it as your own list, and do not cut it short.
97572
99102
  - For build/book: one line "\u2713 <name> \u2014 <url>".
97573
- - Use a plain "#<id> <name>" style. No tables, no fluff, no "Here's what I found". If a result count is low, state the number plainly \u2014 do NOT speculate about why unless asked.
99103
+ - For your OWN summaries use a plain "#<id> <name>" style \u2014 no fluff, no "Here's what I found".
99104
+ (This does not apply to \`sproutkit list\`, whose own table you pass straight through.)
99105
+ If a result count is low, state the number plainly \u2014 do NOT speculate about why unless asked.
97574
99106
 
97575
99107
  GATHER REQUIRED CHOICES FIRST \u2014 ask, don't guess, don't chatter:
97576
99108
  When a request is missing information the command needs, ask a SHORT, STRUCTURED question that lists the options \u2014 then wait. Present choices as a tight numbered/bulleted list, no rambling. Specifically:
97577
- - Which business/lead is not clear \u2192 run \`sproutkit list --json\`, then ask: "Which business?" and list a few (id + name).
99109
+ - Which business/lead is not clear \u2192 run \`sproutkit list --json\` to read ids (don't print the
99110
+ JSON), then ask: "Which business?" and name a few (id + name).
97578
99111
  - "make/build a booking site" \u2192 you MUST establish the booking TYPE before running. Ask: "What type of booking site?" and present exactly:
97579
99112
  1. Slot-based \u2014 timed appointments (barbers, salons, clinics)
97580
99113
  2. Reservation \u2014 table / party booking (restaurants, cafes)
@@ -97585,7 +99118,8 @@ When a request is missing information the command needs, ask a SHORT, STRUCTURED
97585
99118
  COMMANDS:
97586
99119
  - sproutkit find "<area>" --categories "<type>,<type>" --json \u2014 find website-less leads
97587
99120
  - sproutkit find "<area>" --categories "\u2026" --booking --json \u2014 find businesses that HAVE a site but take bookings via WhatsApp/phone only (booking-page prospects)
97588
- - sproutkit list --json [--status <s>] [--city <c>] \u2014 list leads (get ids)
99121
+ - sproutkit list [--status <s>] [--city <c>] [--limit 100] \u2014 formatted table; SHOW IT VERBATIM
99122
+ - sproutkit list --json \u2014 same data as JSON, for reading ids only
97589
99123
  - sproutkit build <id> --json [--instructions "..."] [--cms] [--multipage] \u2014 build a demo site (--multipage = real Home/Services/About/Contact pages, not one scroll)
97590
99124
  - sproutkit build-all --json [--ids 3,5] [--concurrency 3] \u2014 build many at once
97591
99125
  - sproutkit regenerate <id> --instructions "..." --json \u2014 rebuild an existing site with changes
@@ -97593,6 +99127,13 @@ COMMANDS:
97593
99127
  - sproutkit book "<Business Name, Town>" --json \u2014 ONE-SHOT: look up any business (even not a lead), read their site, build them a standalone booking page
97594
99128
  - sproutkit book <id> --json [--standalone] [--instructions "..."] [--archetype slots|reservation|enquiry] [--email x@y.com] \u2014 booking site (--standalone = its own URL for clients who already have a website)
97595
99129
  - sproutkit outreach --json [--ids 3,5] \u2014 prepare outreach links
99130
+ - sproutkit reply <id> --interested|--not-interested|--no-answer|--callback --json \u2014 log a response
99131
+ - sproutkit followups [--days 3] --json \u2014 who never replied + funnel stats
99132
+ - sproutkit report <id> --json \u2014 a live site's calls/bookings/views
99133
+ - sproutkit brand --show \u2014 the agency's own details (invoices, credits)
99134
+ - sproutkit invoice <id> --items "Website:1200,Booking:450" --json \u2014 PDF invoice on their letterhead
99135
+ - sproutkit invoice list [--unpaid] | invoice paid <number> \u2014 invoice ledger
99136
+ - sproutkit letter <id> --subject "..." --body "..." --json \u2014 letter/proposal on the letterhead
97596
99137
  - sproutkit pipeline x3 --json [--book] [--limit 10] \u2014 full pipeline (build\u2192booking\u2192outreach) for many clients concurrently
97597
99138
 
97598
99139
  If no command fits the request, say so in one line and name the closest thing you can do. Never invent results.`;
@@ -97776,7 +99317,7 @@ async function main() {
97776
99317
  closeTty();
97777
99318
  return;
97778
99319
  }
97779
- if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "handover" || arg === "outreach" || arg === "pipeline") {
99320
+ if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "handover" || arg === "brand" || arg === "invoice" || arg === "letter" || arg === "reply" || arg === "followups" || arg === "report" || arg === "outreach" || arg === "pipeline") {
97780
99321
  const subArgs = process.argv.slice(3);
97781
99322
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
97782
99323
  const USAGE = {
@@ -97816,6 +99357,41 @@ async function main() {
97816
99357
  ' --instructions "\u2026" e.g. "45-min slots, add beard trim \xA312"',
97817
99358
  " --email <addr> owner email for booking notifications"
97818
99359
  ],
99360
+ brand: [
99361
+ 'sproutkit brand --name "Your Studio" --email you@x.com [--vat-rate 20]',
99362
+ " your agency identity \u2014 goes on invoices, letters, the site footer",
99363
+ " credit, the demo banner and the /admin title",
99364
+ " --interactive walk through every field",
99365
+ " --show print what's currently set"
99366
+ ],
99367
+ invoice: [
99368
+ 'sproutkit invoice <leadId> --items "Website build:1200,Booking:450"',
99369
+ " renders a PDF invoice on your letterhead",
99370
+ ' quantities: --items "Monthly hosting:12x25"',
99371
+ " --due <days> payment terms (default 14)",
99372
+ " sproutkit invoice list [--unpaid]",
99373
+ " sproutkit invoice paid INV-2026-001"
99374
+ ],
99375
+ letter: [
99376
+ 'sproutkit letter <leadId> --subject "..." --body "..."',
99377
+ " a letter/proposal on the same letterhead",
99378
+ " --body-file <path> read the body from a file",
99379
+ ` --to "Name" write to someone who isn't a lead`
99380
+ ],
99381
+ reply: [
99382
+ "sproutkit reply <leadId> --interested | --not-interested | --no-answer",
99383
+ " log how a business responded, so follow-ups know who to chase",
99384
+ " --wrong-number | --callback",
99385
+ ' --note "wants menu options first"'
99386
+ ],
99387
+ followups: [
99388
+ "sproutkit followups [--days 3]",
99389
+ " who was contacted and never replied, plus your funnel stats"
99390
+ ],
99391
+ report: [
99392
+ "sproutkit report <leadId>",
99393
+ " a client site's calls/bookings/views, last 30 days vs the 30 before"
99394
+ ],
97819
99395
  handover: [
97820
99396
  "sproutkit handover <leadId> [--domain thesalon.co.uk]",
97821
99397
  " the paid-deliverable step: removes the DEMO banner, attaches the",
@@ -97858,6 +99434,12 @@ async function main() {
97858
99434
  else if (arg === "cms") await cmdCms(subArgs);
97859
99435
  else if (arg === "book") await cmdBook(subArgs);
97860
99436
  else if (arg === "handover") await cmdHandover(subArgs);
99437
+ else if (arg === "brand") await cmdBrand(subArgs);
99438
+ else if (arg === "invoice") await cmdInvoice(subArgs);
99439
+ else if (arg === "letter") await cmdLetter(subArgs);
99440
+ else if (arg === "reply") await cmdReply(subArgs);
99441
+ else if (arg === "followups") await cmdFollowups(subArgs);
99442
+ else if (arg === "report") await cmdReport(subArgs);
97861
99443
  else if (arg === "pipeline") await cmdPipeline(subArgs);
97862
99444
  else await cmdOutreach(subArgs);
97863
99445
  closeTty();