sproutkit-code 1.5.2 → 1.6.1

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';
@@ -80962,7 +81377,9 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
80962
81377
  version: "0.0.1",
80963
81378
  type: "module",
80964
81379
  scripts: { build: "vite build", dev: "vite" },
80965
- dependencies: { ...baseDeps },
81380
+ // @vercel/blob even without CMS: /api/track writes the visit counters, and
81381
+ // analytics ships on every site.
81382
+ dependencies: { ...baseDeps, "@vercel/blob": "^0.23.4" },
80966
81383
  devDependencies: { "@vitejs/plugin-react": "^4.3.1", vite: "^5.4.2" }
80967
81384
  });
80968
81385
  const viteConfig = `import { defineConfig } from 'vite';
@@ -81057,20 +81474,33 @@ export default defineConfig({ plugins: [react()] });`;
81057
81474
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81058
81475
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_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 };
81064
81481
  }
81482
+ const analyticsFiles = [
81483
+ { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81484
+ { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }
81485
+ ];
81065
81486
  const allFiles = [
81066
81487
  { file: "index.html", data: indexHtml, encoding: "utf-8" },
81067
81488
  { file: "src/main.jsx", data: mainJsx, encoding: "utf-8" },
81068
81489
  { file: "src/App.jsx", data: appCode, encoding: "utf-8" },
81069
81490
  { file: "package.json", data: finalPackageJson, encoding: "utf-8" },
81070
81491
  { file: "vite.config.js", data: viteConfig, encoding: "utf-8" },
81492
+ ...analyticsFiles,
81071
81493
  ...cmsExtraFiles,
81072
81494
  ...photoFiles
81073
81495
  ];
81496
+ if (!includeCms && blobToken) {
81497
+ onProgress("Setting analytics env vars\u2026");
81498
+ await setProjectEnv(projectName, vercelToken, {
81499
+ BLOB_READ_WRITE_TOKEN: blobToken,
81500
+ SITE_ID: siteUidFor(lead.id),
81501
+ BUSINESS_NAME: lead.name
81502
+ });
81503
+ }
81074
81504
  const totalMb = allFiles.reduce((s, f) => s + f.data.length, 0) / 1048576;
81075
81505
  onProgress(`Uploading ${allFiles.length} files (${totalMb.toFixed(1)} MB)\u2026`);
81076
81506
  const res = await postVercelDeployment({
@@ -81145,7 +81575,7 @@ export default defineConfig({ plugins: [react()] });`;
81145
81575
  { source: "/book/", destination: "/book/index.html" }
81146
81576
  ]
81147
81577
  });
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>`;
81578
+ 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
81579
  const mainJsxCms = `import { StrictMode } from 'react';
81150
81580
  import { createRoot } from 'react-dom/client';
81151
81581
  import App from './App.jsx';
@@ -81153,7 +81583,7 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
81153
81583
  const viteConfigCms = `import { defineConfig } from 'vite';
81154
81584
  import react from '@vitejs/plugin-react';
81155
81585
  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" });
81586
+ 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
81587
  if (!filesToRedeploy.some((f) => f.file === "src/App.jsx")) {
81158
81588
  throw new Error("Couldn't fetch src/App.jsx from the existing deployment. Try Regenerate website first, then Redeploy CMS.");
81159
81589
  }
@@ -81223,7 +81653,7 @@ export default defineConfig({ plugins: [react()] });`;
81223
81653
  snapshot[appJsxIndex] = { ...snapshot[appJsxIndex], data: modifiedApp };
81224
81654
  saveSnapshot(lead.id, snapshot);
81225
81655
  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>`;
81656
+ 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
81657
  const mainJsx = `import { StrictMode } from 'react';
81228
81658
  import { createRoot } from 'react-dom/client';
81229
81659
  import App from './App.jsx';
@@ -81305,12 +81735,14 @@ export default defineConfig({ plugins: [react()] });`;
81305
81735
  },
81306
81736
  { file: "public/book/app.js", data: booking_runtime_1.BOOK_APP_JS, encoding: "utf-8" },
81307
81737
  // 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" },
81738
+ { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
81309
81739
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" },
81310
81740
  { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" },
81311
81741
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81312
81742
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81313
81743
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
81744
+ { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81745
+ { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
81314
81746
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }
81315
81747
  );
81316
81748
  onProgress("Deploying\u2026");
@@ -81366,11 +81798,13 @@ export default defineConfig({ plugins: [react()] });`;
81366
81798
  { file: "api/booking-config.js", data: booking_runtime_1.API_BOOKING_CONFIG_JS, encoding: "utf-8" },
81367
81799
  // The client's own dashboard: view bookings, edit services/hours. Same
81368
81800
  // 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" },
81801
+ { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
81370
81802
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" },
81371
81803
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81372
81804
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81373
81805
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
81806
+ { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81807
+ { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
81374
81808
  // Serve /admin without a trailing-slash redirect dance.
81375
81809
  {
81376
81810
  file: "vercel.json",
@@ -81512,7 +81946,7 @@ export default defineConfig({ plugins: [react()] });`;
81512
81946
  deps["@vercel/blob"] = "^0.23.4";
81513
81947
  deps.bcryptjs = "^2.4.3";
81514
81948
  }
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>`;
81949
+ 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
81950
  files.push({ file: "index.html", data: indexHtml, encoding: "utf-8" }, {
81517
81951
  file: "src/main.jsx",
81518
81952
  data: `import { StrictMode } from 'react';
@@ -81552,7 +81986,7 @@ export default defineConfig({ plugins: [react()] });`,
81552
81986
  ]
81553
81987
  }),
81554
81988
  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" });
81989
+ }, { 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
81990
  const env = {
81557
81991
  BLOB_READ_WRITE_TOKEN: blobToken,
81558
81992
  ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
@@ -82235,6 +82669,541 @@ var require_lead_search = __commonJS({
82235
82669
  }
82236
82670
  });
82237
82671
 
82672
+ // ../desktop/dist/main/lib/followup.js
82673
+ var require_followup = __commonJS({
82674
+ "../desktop/dist/main/lib/followup.js"(exports2) {
82675
+ "use strict";
82676
+ Object.defineProperty(exports2, "__esModule", { value: true });
82677
+ exports2.logContact = logContact;
82678
+ exports2.dueFollowUps = dueFollowUps2;
82679
+ exports2.contactHistory = contactHistory2;
82680
+ exports2.outreachStats = outreachStats2;
82681
+ var db_1 = require_db();
82682
+ var STATUS_FOR = {
82683
+ interested: "replied",
82684
+ callback: "replied",
82685
+ // Not a lost cause forever, but out of the active funnel.
82686
+ not_interested: "dead",
82687
+ wrong_number: "dead",
82688
+ // Silence isn't an outcome that changes the stage — they're still 'contacted'.
82689
+ no_answer: null
82690
+ };
82691
+ function logContact(args) {
82692
+ const db2 = (0, db_1.getDb)();
82693
+ const lead = db2.get("SELECT id FROM leads WHERE id = ?", [args.leadId]);
82694
+ if (!lead)
82695
+ throw new Error(`Lead #${args.leadId} not found`);
82696
+ 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]);
82697
+ if (args.direction === "out") {
82698
+ db2.run(`UPDATE leads
82699
+ SET last_contacted_at = datetime('now'),
82700
+ followed_up_count = COALESCE(followed_up_count, 0) + 1
82701
+ WHERE id = ?`, [args.leadId]);
82702
+ return;
82703
+ }
82704
+ const nextStatus = args.outcome ? STATUS_FOR[args.outcome] : null;
82705
+ db2.run(`UPDATE leads
82706
+ SET last_reply_at = datetime('now'),
82707
+ reply_outcome = ?,
82708
+ status = CASE
82709
+ -- Never drag a client BACKWARDS out of won.
82710
+ WHEN status = 'won' THEN status
82711
+ WHEN ? IS NULL THEN status
82712
+ ELSE ?
82713
+ END,
82714
+ dead_at = CASE WHEN ? = 'dead' THEN datetime('now') ELSE dead_at END
82715
+ WHERE id = ?`, [args.outcome ?? null, nextStatus, nextStatus, nextStatus, args.leadId]);
82716
+ }
82717
+ function dueFollowUps2(afterDays = 3, limit = 50) {
82718
+ return (0, db_1.getDb)().all(`SELECT id, name, city, phone, email, status, site_url, booking_url,
82719
+ last_contacted_at, followed_up_count,
82720
+ CAST(julianday('now') - julianday(last_contacted_at) AS INTEGER) AS days_since
82721
+ FROM leads
82722
+ WHERE last_contacted_at IS NOT NULL
82723
+ AND last_reply_at IS NULL
82724
+ AND status NOT IN ('dead', 'won')
82725
+ AND julianday('now') - julianday(last_contacted_at) >= ?
82726
+ ORDER BY last_contacted_at ASC
82727
+ LIMIT ?`, [afterDays, limit]);
82728
+ }
82729
+ function contactHistory2(leadId) {
82730
+ return (0, db_1.getDb)().all("SELECT * FROM contact_log WHERE lead_id = ? ORDER BY at DESC", [leadId]);
82731
+ }
82732
+ function outreachStats2() {
82733
+ const db2 = (0, db_1.getDb)();
82734
+ const one = (sql, params = []) => Number(db2.get(sql, params)?.n ?? 0);
82735
+ const contacted = one("SELECT COUNT(*) n FROM leads WHERE last_contacted_at IS NOT NULL");
82736
+ const replied = one("SELECT COUNT(*) n FROM leads WHERE last_reply_at IS NOT NULL");
82737
+ const interested = one("SELECT COUNT(*) n FROM leads WHERE reply_outcome IN ('interested','callback')");
82738
+ const won = one("SELECT COUNT(*) n FROM leads WHERE status = 'won'");
82739
+ const awaitingReply = one(`SELECT COUNT(*) n FROM leads
82740
+ WHERE last_contacted_at IS NOT NULL AND last_reply_at IS NULL
82741
+ AND status NOT IN ('dead','won')`);
82742
+ return {
82743
+ contacted,
82744
+ replied,
82745
+ interested,
82746
+ won,
82747
+ awaitingReply,
82748
+ replyRatePct: contacted ? Math.round(replied / contacted * 100) : 0
82749
+ };
82750
+ }
82751
+ }
82752
+ });
82753
+
82754
+ // ../desktop/dist/main/lib/invoicing.js
82755
+ var require_invoicing = __commonJS({
82756
+ "../desktop/dist/main/lib/invoicing.js"(exports2) {
82757
+ "use strict";
82758
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? (function(o, m, k, k2) {
82759
+ if (k2 === void 0) k2 = k;
82760
+ var desc = Object.getOwnPropertyDescriptor(m, k);
82761
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
82762
+ desc = { enumerable: true, get: function() {
82763
+ return m[k];
82764
+ } };
82765
+ }
82766
+ Object.defineProperty(o, k2, desc);
82767
+ }) : (function(o, m, k, k2) {
82768
+ if (k2 === void 0) k2 = k;
82769
+ o[k2] = m[k];
82770
+ }));
82771
+ var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? (function(o, v) {
82772
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
82773
+ }) : function(o, v) {
82774
+ o["default"] = v;
82775
+ });
82776
+ var __importStar = exports2 && exports2.__importStar || /* @__PURE__ */ (function() {
82777
+ var ownKeys = function(o) {
82778
+ ownKeys = Object.getOwnPropertyNames || function(o2) {
82779
+ var ar = [];
82780
+ for (var k in o2) if (Object.prototype.hasOwnProperty.call(o2, k)) ar[ar.length] = k;
82781
+ return ar;
82782
+ };
82783
+ return ownKeys(o);
82784
+ };
82785
+ return function(mod) {
82786
+ if (mod && mod.__esModule) return mod;
82787
+ var result = {};
82788
+ if (mod != null) {
82789
+ for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
82790
+ }
82791
+ __setModuleDefault(result, mod);
82792
+ return result;
82793
+ };
82794
+ })();
82795
+ var __importDefault = exports2 && exports2.__importDefault || function(mod) {
82796
+ return mod && mod.__esModule ? mod : { "default": mod };
82797
+ };
82798
+ Object.defineProperty(exports2, "__esModule", { value: true });
82799
+ exports2.parseMoney = parseMoney;
82800
+ exports2.parseLineItems = parseLineItems;
82801
+ exports2.nextInvoiceNumber = nextInvoiceNumber;
82802
+ exports2.invoiceDir = invoiceDir;
82803
+ exports2.createInvoice = createInvoice;
82804
+ exports2.getInvoiceByNumber = getInvoiceByNumber;
82805
+ exports2.listInvoices = listInvoices;
82806
+ exports2.markInvoicePaid = markInvoicePaid;
82807
+ exports2.renderInvoicePdf = renderInvoicePdf;
82808
+ exports2.renderLetterPdf = renderLetterPdf2;
82809
+ var pdfkit_1 = __importDefault(require("pdfkit"));
82810
+ var fs3 = __importStar(require("fs"));
82811
+ var path3 = __importStar(require("path"));
82812
+ var db_1 = require_db();
82813
+ var runtime_1 = require_runtime();
82814
+ var agency_1 = require_agency();
82815
+ function parseMoney(input) {
82816
+ const cleaned = String(input).trim().replace(/[£$€,\s]/g, "");
82817
+ if (!/^\d+(\.\d{1,2})?$/.test(cleaned)) {
82818
+ throw new Error(`"${input}" isn't a valid amount (expected e.g. 600 or 600.50).`);
82819
+ }
82820
+ const [whole, frac = ""] = cleaned.split(".");
82821
+ return Number(whole) * 100 + Number(frac.padEnd(2, "0"));
82822
+ }
82823
+ function parseLineItems(spec) {
82824
+ const items = [];
82825
+ for (const chunk of spec.split(",")) {
82826
+ const part = chunk.trim();
82827
+ if (!part)
82828
+ continue;
82829
+ const idx = part.lastIndexOf(":");
82830
+ if (idx === -1) {
82831
+ throw new Error(`"${part}" needs a price \u2014 use "Description:600".`);
82832
+ }
82833
+ const description = part.slice(0, idx).trim();
82834
+ const amount = part.slice(idx + 1).trim();
82835
+ if (!description)
82836
+ throw new Error(`Missing a description in "${part}".`);
82837
+ const qtyMatch = amount.match(/^(\d+)\s*[x*]\s*(.+)$/i);
82838
+ if (qtyMatch) {
82839
+ items.push({
82840
+ description,
82841
+ quantity: Number(qtyMatch[1]),
82842
+ unitPence: parseMoney(qtyMatch[2])
82843
+ });
82844
+ } else {
82845
+ items.push({ description, quantity: 1, unitPence: parseMoney(amount) });
82846
+ }
82847
+ }
82848
+ if (items.length === 0)
82849
+ throw new Error("No line items given.");
82850
+ return items;
82851
+ }
82852
+ function nextInvoiceNumber() {
82853
+ const year = (/* @__PURE__ */ new Date()).getFullYear();
82854
+ const prefix = `INV-${year}-`;
82855
+ const rows = (0, db_1.getDb)().all("SELECT number FROM invoices WHERE number LIKE ? ORDER BY number DESC LIMIT 1", [prefix + "%"]);
82856
+ const last = rows[0]?.number;
82857
+ const n = last ? Number(last.slice(prefix.length)) + 1 : 1;
82858
+ return prefix + String(n).padStart(3, "0");
82859
+ }
82860
+ function invoiceDir() {
82861
+ const dir = path3.join((0, runtime_1.userDataDir)(), "data", "invoices");
82862
+ fs3.mkdirSync(dir, { recursive: true });
82863
+ return dir;
82864
+ }
82865
+ function addDays(iso, days) {
82866
+ const d = /* @__PURE__ */ new Date(iso + "T00:00:00Z");
82867
+ d.setUTCDate(d.getUTCDate() + days);
82868
+ return d.toISOString().slice(0, 10);
82869
+ }
82870
+ function createInvoice(args) {
82871
+ const profile = (0, agency_1.getAgencyProfile)();
82872
+ const subtotal = args.items.reduce((sum, i) => sum + i.quantity * i.unitPence, 0);
82873
+ const rate = args.vatRate ?? profile.vatRate ?? 0;
82874
+ const vat = rate > 0 ? Math.round(subtotal * rate / 100) : 0;
82875
+ const total = subtotal + vat;
82876
+ const issued = args.issuedAt ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
82877
+ const number = nextInvoiceNumber();
82878
+ const db2 = (0, db_1.getDb)();
82879
+ db2.run(`INSERT INTO invoices
82880
+ (number, lead_id, client_name, client_email, client_address, line_items,
82881
+ subtotal_pence, vat_pence, total_pence, currency, status, issued_at, due_at, notes)
82882
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?)`, [
82883
+ number,
82884
+ args.leadId ?? null,
82885
+ args.clientName,
82886
+ args.clientEmail ?? null,
82887
+ args.clientAddress ?? null,
82888
+ JSON.stringify(args.items),
82889
+ subtotal,
82890
+ vat,
82891
+ total,
82892
+ profile.currency,
82893
+ issued,
82894
+ addDays(issued, args.dueInDays ?? 14),
82895
+ args.notes ?? null
82896
+ ]);
82897
+ return getInvoiceByNumber(number);
82898
+ }
82899
+ function getInvoiceByNumber(number) {
82900
+ return (0, db_1.getDb)().get("SELECT * FROM invoices WHERE number = ?", [number]) ?? null;
82901
+ }
82902
+ function listInvoices(filter) {
82903
+ let sql = "SELECT * FROM invoices";
82904
+ const conds = [];
82905
+ const params = [];
82906
+ if (filter?.status) {
82907
+ conds.push("status = ?");
82908
+ params.push(filter.status);
82909
+ }
82910
+ if (filter?.leadId) {
82911
+ conds.push("lead_id = ?");
82912
+ params.push(filter.leadId);
82913
+ }
82914
+ if (conds.length)
82915
+ sql += " WHERE " + conds.join(" AND ");
82916
+ sql += " ORDER BY id DESC";
82917
+ return (0, db_1.getDb)().all(sql, params);
82918
+ }
82919
+ function markInvoicePaid(number, when) {
82920
+ const inv = getInvoiceByNumber(number);
82921
+ if (!inv)
82922
+ throw new Error(`Invoice ${number} not found.`);
82923
+ (0, db_1.getDb)().run("UPDATE invoices SET status = 'paid', paid_at = ? WHERE number = ?", [
82924
+ when ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
82925
+ number
82926
+ ]);
82927
+ return getInvoiceByNumber(number);
82928
+ }
82929
+ var PAGE_MARGIN = 50;
82930
+ var INK = "#141412";
82931
+ var MUTED = "#6a6a5e";
82932
+ function hexOrDefault(hex, fallback) {
82933
+ return hex && /^#[0-9a-f]{6}$/i.test(hex) ? hex : fallback;
82934
+ }
82935
+ function drawLetterhead(doc, p) {
82936
+ const accent = hexOrDefault(p.accent, "#556B45");
82937
+ const right = doc.page.width - PAGE_MARGIN;
82938
+ let leftBottom = PAGE_MARGIN;
82939
+ if (p.logoPath && fs3.existsSync(p.logoPath)) {
82940
+ try {
82941
+ doc.image(p.logoPath, PAGE_MARGIN, PAGE_MARGIN, { fit: [150, 50] });
82942
+ leftBottom = PAGE_MARGIN + 50;
82943
+ } catch {
82944
+ doc.fillColor(INK).fontSize(20).font("Helvetica-Bold").text(p.name ?? "", PAGE_MARGIN, PAGE_MARGIN);
82945
+ leftBottom = doc.y;
82946
+ }
82947
+ } else {
82948
+ doc.fillColor(INK).fontSize(20).font("Helvetica-Bold").text(p.name ?? "", PAGE_MARGIN, PAGE_MARGIN);
82949
+ leftBottom = doc.y;
82950
+ }
82951
+ const details = [
82952
+ p.address,
82953
+ p.phone,
82954
+ p.email,
82955
+ p.website,
82956
+ p.companyNumber ? `Company no. ${p.companyNumber}` : null,
82957
+ p.vatNumber ? `VAT no. ${p.vatNumber}` : null
82958
+ ].filter(Boolean).join("\n");
82959
+ doc.fontSize(9).font("Helvetica").fillColor(MUTED);
82960
+ const w = 200;
82961
+ doc.text(details, right - w, PAGE_MARGIN, { width: w, align: "right" });
82962
+ const rightBottom = doc.y;
82963
+ const y = Math.max(leftBottom, rightBottom) + 18;
82964
+ doc.moveTo(PAGE_MARGIN, y).lineTo(right, y).strokeColor(accent).lineWidth(2).stroke();
82965
+ return y + 24;
82966
+ }
82967
+ function drawFooter(doc, p) {
82968
+ const y = doc.page.height - PAGE_MARGIN + 10;
82969
+ const bits = [p.name, p.email, p.phone].filter(Boolean).join(" \xB7 ");
82970
+ doc.fontSize(8).fillColor(MUTED).font("Helvetica").text(bits, PAGE_MARGIN, y, { width: doc.page.width - PAGE_MARGIN * 2, align: "center" });
82971
+ }
82972
+ async function renderInvoicePdf(invoice, outPath) {
82973
+ const p = (0, agency_1.getAgencyProfile)();
82974
+ if (!p.name) {
82975
+ throw new Error("No agency details set \u2014 run `sproutkit brand` first so invoices come from you, not from nobody.");
82976
+ }
82977
+ const accent = hexOrDefault(p.accent, "#556B45");
82978
+ const items = JSON.parse(invoice.line_items);
82979
+ const file = outPath ?? path3.join(invoiceDir(), `${invoice.number}.pdf`);
82980
+ const doc = new pdfkit_1.default({ size: "A4", margin: PAGE_MARGIN, info: {
82981
+ Title: `Invoice ${invoice.number}`,
82982
+ Author: p.name
82983
+ } });
82984
+ const stream = fs3.createWriteStream(file);
82985
+ doc.pipe(stream);
82986
+ let y = drawLetterhead(doc, p);
82987
+ const right = doc.page.width - PAGE_MARGIN;
82988
+ doc.fillColor(INK).font("Helvetica-Bold").fontSize(24).text("INVOICE", PAGE_MARGIN, y);
82989
+ doc.font("Helvetica").fontSize(10).fillColor(MUTED);
82990
+ const metaLines = [
82991
+ ["Invoice no.", invoice.number],
82992
+ ["Date", invoice.issued_at ?? ""],
82993
+ ["Due", invoice.due_at ?? ""]
82994
+ ];
82995
+ let metaY = y + 2;
82996
+ for (const [k, v] of metaLines) {
82997
+ doc.fillColor(MUTED).text(k, right - 200, metaY, { width: 100, align: "right" });
82998
+ doc.fillColor(INK).text(v, right - 95, metaY, { width: 95, align: "right" });
82999
+ metaY += 14;
83000
+ }
83001
+ y = Math.max(doc.y, metaY) + 18;
83002
+ doc.fillColor(MUTED).fontSize(9).font("Helvetica-Bold").text("BILL TO", PAGE_MARGIN, y);
83003
+ y = doc.y + 4;
83004
+ doc.fillColor(INK).fontSize(11).font("Helvetica").text(invoice.client_name, PAGE_MARGIN, y);
83005
+ y = doc.y;
83006
+ const clientMeta = [invoice.client_address, invoice.client_email].filter(Boolean).join("\n");
83007
+ if (clientMeta) {
83008
+ doc.fontSize(9).fillColor(MUTED).text(clientMeta, PAGE_MARGIN, y, { width: 250 });
83009
+ y = doc.y;
83010
+ }
83011
+ y += 22;
83012
+ const colDesc = PAGE_MARGIN;
83013
+ const colQty = right - 210;
83014
+ const colUnit = right - 150;
83015
+ const colAmt = right - 70;
83016
+ doc.fontSize(9).font("Helvetica-Bold").fillColor(MUTED);
83017
+ doc.text("DESCRIPTION", colDesc, y);
83018
+ doc.text("QTY", colQty, y, { width: 40, align: "right" });
83019
+ doc.text("UNIT", colUnit, y, { width: 70, align: "right" });
83020
+ doc.text("AMOUNT", colAmt, y, { width: 70, align: "right" });
83021
+ y = doc.y + 6;
83022
+ doc.moveTo(PAGE_MARGIN, y).lineTo(right, y).strokeColor("#e5e5dd").lineWidth(1).stroke();
83023
+ y += 10;
83024
+ doc.font("Helvetica").fontSize(10).fillColor(INK);
83025
+ for (const item of items) {
83026
+ const amount = item.quantity * item.unitPence;
83027
+ const descHeight = doc.heightOfString(item.description, { width: colQty - colDesc - 10 });
83028
+ doc.text(item.description, colDesc, y, { width: colQty - colDesc - 10 });
83029
+ doc.text(String(item.quantity), colQty, y, { width: 40, align: "right" });
83030
+ doc.text((0, agency_1.formatMoney)(item.unitPence, invoice.currency), colUnit, y, { width: 70, align: "right" });
83031
+ doc.text((0, agency_1.formatMoney)(amount, invoice.currency), colAmt, y, { width: 70, align: "right" });
83032
+ y += Math.max(descHeight, 14) + 8;
83033
+ if (y > doc.page.height - 200) {
83034
+ doc.addPage();
83035
+ y = PAGE_MARGIN;
83036
+ }
83037
+ }
83038
+ doc.moveTo(colQty, y).lineTo(right, y).strokeColor("#e5e5dd").stroke();
83039
+ y += 10;
83040
+ const totalRow = (label, value, bold = false) => {
83041
+ doc.font(bold ? "Helvetica-Bold" : "Helvetica").fontSize(bold ? 12 : 10).fillColor(bold ? INK : MUTED).text(label, colUnit - 60, y, { width: 130, align: "right" });
83042
+ doc.fillColor(INK).text(value, colAmt, y, { width: 70, align: "right" });
83043
+ y += bold ? 20 : 16;
83044
+ };
83045
+ totalRow("Subtotal", (0, agency_1.formatMoney)(invoice.subtotal_pence, invoice.currency));
83046
+ if (invoice.vat_pence > 0) {
83047
+ const rate = p.vatRate ?? 0;
83048
+ totalRow(`VAT (${rate}%)`, (0, agency_1.formatMoney)(invoice.vat_pence, invoice.currency));
83049
+ }
83050
+ doc.moveTo(colQty, y).lineTo(right, y).strokeColor(accent).lineWidth(2).stroke();
83051
+ y += 8;
83052
+ totalRow("Total due", (0, agency_1.formatMoney)(invoice.total_pence, invoice.currency), true);
83053
+ if (invoice.status === "paid") {
83054
+ doc.fontSize(11).font("Helvetica-Bold").fillColor(accent).text(`PAID${invoice.paid_at ? " \xB7 " + invoice.paid_at : ""}`, colUnit - 60, y, {
83055
+ width: 200,
83056
+ align: "right"
83057
+ });
83058
+ y = doc.y;
83059
+ }
83060
+ y += 24;
83061
+ const payBits = [
83062
+ p.bankName ? `Bank: ${p.bankName}` : null,
83063
+ p.bankAccount ? `Account: ${p.bankAccount}` : null,
83064
+ p.bankSortCode ? `Sort code: ${p.bankSortCode}` : null
83065
+ ].filter(Boolean);
83066
+ if (payBits.length) {
83067
+ doc.fontSize(9).font("Helvetica-Bold").fillColor(MUTED).text("PAYMENT", PAGE_MARGIN, y);
83068
+ y = doc.y + 4;
83069
+ doc.font("Helvetica").fontSize(10).fillColor(INK).text(payBits.join("\n"), PAGE_MARGIN, y);
83070
+ y = doc.y + 12;
83071
+ }
83072
+ const terms = invoice.notes || p.paymentTerms;
83073
+ if (terms) {
83074
+ doc.fontSize(9).font("Helvetica").fillColor(MUTED).text(terms, PAGE_MARGIN, y, {
83075
+ width: right - PAGE_MARGIN * 2
83076
+ });
83077
+ }
83078
+ drawFooter(doc, p);
83079
+ doc.end();
83080
+ await new Promise((resolve, reject) => {
83081
+ stream.on("finish", () => resolve());
83082
+ stream.on("error", reject);
83083
+ });
83084
+ (0, db_1.getDb)().run("UPDATE invoices SET pdf_path = ? WHERE number = ?", [file, invoice.number]);
83085
+ return file;
83086
+ }
83087
+ async function renderLetterPdf2(args) {
83088
+ const p = (0, agency_1.getAgencyProfile)();
83089
+ if (!p.name) {
83090
+ throw new Error("No agency details set \u2014 run `sproutkit brand` first.");
83091
+ }
83092
+ const dir = path3.join((0, runtime_1.userDataDir)(), "data", "letters");
83093
+ fs3.mkdirSync(dir, { recursive: true });
83094
+ const safe = args.to.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
83095
+ const file = args.outPath ?? path3.join(dir, `letter-${safe}-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}.pdf`);
83096
+ const doc = new pdfkit_1.default({ size: "A4", margin: PAGE_MARGIN, info: {
83097
+ Title: args.subject ?? `Letter to ${args.to}`,
83098
+ Author: p.name
83099
+ } });
83100
+ const stream = fs3.createWriteStream(file);
83101
+ doc.pipe(stream);
83102
+ let y = drawLetterhead(doc, p);
83103
+ const width = doc.page.width - PAGE_MARGIN * 2;
83104
+ doc.fontSize(10).fillColor(MUTED).font("Helvetica").text((/* @__PURE__ */ new Date()).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" }), PAGE_MARGIN, y);
83105
+ y = doc.y + 16;
83106
+ doc.fillColor(INK).fontSize(11).font("Helvetica").text(args.to, PAGE_MARGIN, y);
83107
+ y = doc.y;
83108
+ if (args.toAddress) {
83109
+ doc.fontSize(10).fillColor(MUTED).text(args.toAddress, PAGE_MARGIN, y, { width: 250 });
83110
+ y = doc.y;
83111
+ }
83112
+ y += 20;
83113
+ if (args.subject) {
83114
+ doc.fontSize(12).font("Helvetica-Bold").fillColor(INK).text(args.subject, PAGE_MARGIN, y, { width });
83115
+ y = doc.y + 14;
83116
+ }
83117
+ doc.fontSize(11).font("Helvetica").fillColor(INK);
83118
+ for (const para of args.body.split(/\n\s*\n/)) {
83119
+ doc.text(para.trim(), PAGE_MARGIN, y, { width, align: "left", lineGap: 3 });
83120
+ y = doc.y + 10;
83121
+ if (y > doc.page.height - 150) {
83122
+ doc.addPage();
83123
+ y = PAGE_MARGIN;
83124
+ }
83125
+ }
83126
+ y += 10;
83127
+ doc.text(args.signOff ?? `Kind regards,
83128
+ ${p.name}`, PAGE_MARGIN, y, { width });
83129
+ drawFooter(doc, p);
83130
+ doc.end();
83131
+ await new Promise((resolve, reject) => {
83132
+ stream.on("finish", () => resolve());
83133
+ stream.on("error", reject);
83134
+ });
83135
+ return file;
83136
+ }
83137
+ }
83138
+ });
83139
+
83140
+ // ../desktop/dist/main/lib/site-stats.js
83141
+ var require_site_stats = __commonJS({
83142
+ "../desktop/dist/main/lib/site-stats.js"(exports2) {
83143
+ "use strict";
83144
+ var __importDefault = exports2 && exports2.__importDefault || function(mod) {
83145
+ return mod && mod.__esModule ? mod : { "default": mod };
83146
+ };
83147
+ Object.defineProperty(exports2, "__esModule", { value: true });
83148
+ exports2.fetchSiteStats = fetchSiteStats;
83149
+ exports2.pctChange = pctChange;
83150
+ var axios_1 = __importDefault(require_axios());
83151
+ async function fetchSiteStats(siteUrl, adminPassword) {
83152
+ const base = siteUrl.replace(/\/$/, "");
83153
+ let cookie;
83154
+ try {
83155
+ const login = await axios_1.default.post(`${base}/api/admin/login`, { password: adminPassword }, {
83156
+ timeout: 2e4,
83157
+ headers: { "Content-Type": "application/json" },
83158
+ validateStatus: () => true
83159
+ });
83160
+ if (login.status === 429) {
83161
+ return {
83162
+ ok: false,
83163
+ error: "The site's login is rate limited right now \u2014 try again in a few minutes."
83164
+ };
83165
+ }
83166
+ if (login.status !== 200) {
83167
+ return {
83168
+ ok: false,
83169
+ 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}).`
83170
+ };
83171
+ }
83172
+ const setCookie = login.headers["set-cookie"];
83173
+ const raw = Array.isArray(setCookie) ? setCookie[0] : setCookie;
83174
+ if (!raw)
83175
+ return { ok: false, error: "The site didn't return a session cookie." };
83176
+ cookie = String(raw).split(";")[0];
83177
+ } catch (e) {
83178
+ return { ok: false, error: `Couldn't reach the site: ${e.message}` };
83179
+ }
83180
+ try {
83181
+ const res = await axios_1.default.get(`${base}/api/stats`, {
83182
+ timeout: 2e4,
83183
+ headers: { Cookie: cookie },
83184
+ validateStatus: () => true
83185
+ });
83186
+ if (res.status === 404) {
83187
+ return {
83188
+ ok: false,
83189
+ error: "This site was built before analytics shipped. Run `sproutkit regenerate` on it to add them."
83190
+ };
83191
+ }
83192
+ if (res.status !== 200)
83193
+ return { ok: false, error: `Stats unavailable (${res.status}).` };
83194
+ return { ok: true, data: res.data };
83195
+ } catch (e) {
83196
+ return { ok: false, error: `Couldn't read stats: ${e.message}` };
83197
+ }
83198
+ }
83199
+ function pctChange(now, before) {
83200
+ if (!before)
83201
+ return now > 0 ? null : 0;
83202
+ return Math.round((now - before) / before * 100);
83203
+ }
83204
+ }
83205
+ });
83206
+
82238
83207
  // ../../node_modules/nodemailer/lib/fetch/cookies.js
82239
83208
  var require_cookies2 = __commonJS({
82240
83209
  "../../node_modules/nodemailer/lib/fetch/cookies.js"(exports2, module2) {
@@ -94014,6 +94983,10 @@ var require_chat_tools = __commonJS({
94014
94983
  var lead_search_1 = require_lead_search();
94015
94984
  var places_1 = require_places();
94016
94985
  var booking_detect_1 = require_booking_detect();
94986
+ var followup_1 = require_followup();
94987
+ var invoicing_1 = require_invoicing();
94988
+ var agency_1 = require_agency();
94989
+ var site_stats_1 = require_site_stats();
94017
94990
  var email_1 = require_email();
94018
94991
  var email_harvest_1 = require_email_harvest();
94019
94992
  exports2.TOOLS = [
@@ -94128,6 +95101,79 @@ var require_chat_tools = __commonJS({
94128
95101
  },
94129
95102
  requiresConfirmation: true
94130
95103
  },
95104
+ {
95105
+ name: "log_reply",
95106
+ 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.",
95107
+ input_schema: {
95108
+ type: "object",
95109
+ properties: {
95110
+ id: { type: "number", description: "Lead id that replied." },
95111
+ outcome: {
95112
+ type: "string",
95113
+ enum: ["interested", "not_interested", "no_answer", "wrong_number", "callback"],
95114
+ description: "What happened."
95115
+ },
95116
+ note: { type: "string", description: "Optional free-text note, e.g. 'wants to see menu options first'." }
95117
+ },
95118
+ required: ["id", "outcome"]
95119
+ }
95120
+ },
95121
+ {
95122
+ name: "list_followups",
95123
+ 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).",
95124
+ input_schema: {
95125
+ type: "object",
95126
+ properties: {
95127
+ afterDays: { type: "number", description: "Only include leads contacted at least this many days ago. Default 3." }
95128
+ }
95129
+ }
95130
+ },
95131
+ {
95132
+ name: "create_invoice",
95133
+ 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.",
95134
+ input_schema: {
95135
+ type: "object",
95136
+ properties: {
95137
+ id: { type: "number", description: "Lead id to invoice (fills in name/email/address)." },
95138
+ clientName: { type: "string", description: "Client name, if not invoicing an existing lead." },
95139
+ items: { type: "string", description: "'Website build:1200,Booking system:450' or 'Hosting:12x25'." },
95140
+ dueInDays: { type: "number", description: "Payment terms in days. Default 14." },
95141
+ notes: { type: "string", description: "Optional note printed on the invoice." }
95142
+ },
95143
+ required: ["items"]
95144
+ },
95145
+ requiresConfirmation: true
95146
+ },
95147
+ {
95148
+ name: "list_invoices",
95149
+ description: "List invoices, optionally filtered by status ('issued' or 'paid') or lead id. Shows totals and what's outstanding.",
95150
+ input_schema: {
95151
+ type: "object",
95152
+ properties: {
95153
+ status: { type: "string", enum: ["issued", "paid"], description: "Filter by status." },
95154
+ id: { type: "number", description: "Only invoices for this lead." }
95155
+ }
95156
+ }
95157
+ },
95158
+ {
95159
+ name: "mark_invoice_paid",
95160
+ description: "Mark an invoice as paid by its number, e.g. INV-2026-001.",
95161
+ input_schema: {
95162
+ type: "object",
95163
+ properties: { number: { type: "string", description: "Invoice number." } },
95164
+ required: ["number"]
95165
+ },
95166
+ requiresConfirmation: true
95167
+ },
95168
+ {
95169
+ name: "site_report",
95170
+ 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).",
95171
+ input_schema: {
95172
+ type: "object",
95173
+ properties: { id: { type: "number", description: "Lead id." } },
95174
+ required: ["id"]
95175
+ }
95176
+ },
94131
95177
  {
94132
95178
  name: "hand_over_site",
94133
95179
  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 +95653,11 @@ Interested?`;
94607
95653
  (/* @__PURE__ */ new Date()).toISOString(),
94608
95654
  r.id
94609
95655
  ]);
95656
+ (0, followup_1.logContact)({
95657
+ leadId: r.id,
95658
+ direction: "out",
95659
+ channel: e164 ? channel : "email"
95660
+ });
94610
95661
  }
94611
95662
  }
94612
95663
  ctx.emitProgress?.({ channel: "leads:changed", payload: { reason: "prepare_outreach" } });
@@ -94803,6 +95854,116 @@ Interested?`;
94803
95854
  });
94804
95855
  return { ok: true, id, ...result };
94805
95856
  }
95857
+ case "log_reply": {
95858
+ const id = Number(input.id);
95859
+ const outcome = String(input.outcome);
95860
+ const lead = db2.get("SELECT id, name, status FROM leads WHERE id = ?", [id]);
95861
+ if (!lead)
95862
+ throw new Error(`Lead #${id} not found`);
95863
+ (0, followup_1.logContact)({
95864
+ leadId: id,
95865
+ direction: "in",
95866
+ outcome,
95867
+ note: input.note ?? null
95868
+ });
95869
+ const after = db2.get("SELECT status FROM leads WHERE id = ?", [id]);
95870
+ ctx.emitProgress?.({ channel: "leads:changed", payload: { reason: "log_reply" } });
95871
+ return { ok: true, id, business: lead.name, outcome, status: after.status };
95872
+ }
95873
+ case "list_followups": {
95874
+ const afterDays = Number(input.afterDays ?? 3);
95875
+ return { ok: true, afterDays, due: (0, followup_1.dueFollowUps)(afterDays), stats: (0, followup_1.outreachStats)() };
95876
+ }
95877
+ case "create_invoice": {
95878
+ const items = (0, invoicing_1.parseLineItems)(String(input.items));
95879
+ let leadId = null;
95880
+ let clientName = input.clientName ?? "";
95881
+ let clientEmail = null;
95882
+ let clientAddress = null;
95883
+ if (input.id != null) {
95884
+ leadId = Number(input.id);
95885
+ const lead = db2.get("SELECT id, name, email, owner_email, address FROM leads WHERE id = ?", [leadId]);
95886
+ if (!lead)
95887
+ throw new Error(`Lead #${leadId} not found`);
95888
+ clientName = clientName || lead.name;
95889
+ clientEmail = lead.owner_email || lead.email;
95890
+ clientAddress = lead.address;
95891
+ }
95892
+ if (!clientName) {
95893
+ return { ok: false, error: "Who is this invoice for? Give a lead id or a clientName." };
95894
+ }
95895
+ const invoice = (0, invoicing_1.createInvoice)({
95896
+ leadId,
95897
+ clientName,
95898
+ clientEmail,
95899
+ clientAddress,
95900
+ items,
95901
+ dueInDays: input.dueInDays != null ? Number(input.dueInDays) : void 0,
95902
+ notes: input.notes ?? null
95903
+ });
95904
+ const pdf = await (0, invoicing_1.renderInvoicePdf)(invoice);
95905
+ return {
95906
+ ok: true,
95907
+ number: invoice.number,
95908
+ client: invoice.client_name,
95909
+ subtotal: (0, agency_1.formatMoney)(invoice.subtotal_pence, invoice.currency),
95910
+ vat: invoice.vat_pence ? (0, agency_1.formatMoney)(invoice.vat_pence, invoice.currency) : null,
95911
+ total: (0, agency_1.formatMoney)(invoice.total_pence, invoice.currency),
95912
+ due: invoice.due_at,
95913
+ pdf
95914
+ };
95915
+ }
95916
+ case "list_invoices": {
95917
+ const rows = (0, invoicing_1.listInvoices)({
95918
+ status: input.status,
95919
+ leadId: input.id != null ? Number(input.id) : void 0
95920
+ });
95921
+ const outstanding = rows.filter((r) => r.status !== "paid").reduce((sum, r) => sum + r.total_pence, 0);
95922
+ return {
95923
+ ok: true,
95924
+ invoices: rows.map((r) => ({
95925
+ number: r.number,
95926
+ client: r.client_name,
95927
+ total: (0, agency_1.formatMoney)(r.total_pence, r.currency),
95928
+ status: r.status,
95929
+ issued: r.issued_at,
95930
+ due: r.due_at,
95931
+ paid: r.paid_at,
95932
+ pdf: r.pdf_path
95933
+ })),
95934
+ outstanding: (0, agency_1.formatMoney)(outstanding)
95935
+ };
95936
+ }
95937
+ case "mark_invoice_paid": {
95938
+ const invoice = (0, invoicing_1.markInvoicePaid)(String(input.number));
95939
+ return {
95940
+ ok: true,
95941
+ number: invoice.number,
95942
+ client: invoice.client_name,
95943
+ total: (0, agency_1.formatMoney)(invoice.total_pence, invoice.currency),
95944
+ paid: invoice.paid_at
95945
+ };
95946
+ }
95947
+ case "site_report": {
95948
+ const id = Number(input.id);
95949
+ const lead = db2.get("SELECT id, name, site_url, booking_url, admin_url, admin_password, booking_admin_password FROM leads WHERE id = ?", [id]);
95950
+ if (!lead)
95951
+ throw new Error(`Lead #${id} not found`);
95952
+ const base = lead.site_url || lead.booking_url;
95953
+ if (!base)
95954
+ return { ok: false, error: `#${id} ${lead.name} has no live site yet.` };
95955
+ const password = lead.admin_password || lead.booking_admin_password;
95956
+ if (!password) {
95957
+ return {
95958
+ ok: false,
95959
+ error: `No admin password stored for #${id}, so I can't read its stats. Rebuild or hand over the site to provision one.`
95960
+ };
95961
+ }
95962
+ const stats = await (0, site_stats_1.fetchSiteStats)(base, password);
95963
+ if (!stats.ok)
95964
+ return { ok: false, error: stats.error, business: lead.name };
95965
+ return { ok: true, id, business: lead.name, url: base, ...stats.data };
95966
+ }
94806
95967
  case "hand_over_site": {
94807
95968
  const id = Number(input.id);
94808
95969
  const lead = db2.get("SELECT id, name, site_url, booking_url FROM leads WHERE id = ?", [id]);
@@ -95836,7 +96997,7 @@ var require_package2 = __commonJS({
95836
96997
  "package.json"(exports2, module2) {
95837
96998
  module2.exports = {
95838
96999
  name: "sproutkit-code",
95839
- version: "1.5.2",
97000
+ version: "1.6.1",
95840
97001
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
95841
97002
  bin: {
95842
97003
  sproutkit: "./dist/index.js"
@@ -95865,7 +97026,8 @@ var require_package2 = __commonJS({
95865
97026
  dependencies: {
95866
97027
  "@anthropic-ai/sdk": "^0.30.1",
95867
97028
  axios: "^1.7.1",
95868
- "node-sqlite3-wasm": "^0.8.20"
97029
+ "node-sqlite3-wasm": "^0.8.20",
97030
+ pdfkit: "^0.20.1"
95869
97031
  },
95870
97032
  devDependencies: {
95871
97033
  "@types/node": "^20.14.0",
@@ -95884,6 +97046,9 @@ var cms = require_cms_provision();
95884
97046
  var bookingPreset = require_booking_preset();
95885
97047
  var builder = require_builder();
95886
97048
  var vercelStore = require_vercel_store();
97049
+ var agency = require_agency();
97050
+ var followup = require_followup();
97051
+ var invoicing = require_invoicing();
95887
97052
  var models = require_models();
95888
97053
  var llm = require_llm();
95889
97054
  var initDatabase = db.initDatabase;
@@ -95902,6 +97067,15 @@ var refreshModels = models.refreshModels;
95902
97067
  var refreshIfStale = models.refreshIfStale;
95903
97068
  var listCachedModels = models.listCachedModels;
95904
97069
  var getPreferredModel = models.getPreferredModel;
97070
+ var getAgencyProfile = agency.getAgencyProfile;
97071
+ var setAgencyProfile = agency.setAgencyProfile;
97072
+ var agencyConfigured = agency.agencyConfigured;
97073
+ var formatMoney = agency.formatMoney;
97074
+ var dueFollowUps = followup.dueFollowUps;
97075
+ var outreachStats = followup.outreachStats;
97076
+ var contactHistory = followup.contactHistory;
97077
+ var renderLetterPdf = invoicing.renderLetterPdf;
97078
+ var listInvoicesRaw = invoicing.listInvoices;
95905
97079
  var checkClaudeCodeAuth = llm.checkClaudeCodeAuth;
95906
97080
  var llmBackend = llm.getBackend;
95907
97081
  var claudeBin = llm.claudeBin;
@@ -97331,6 +98505,370 @@ async function cmdHandover(argv) {
97331
98505
  process.exitCode = 1;
97332
98506
  }
97333
98507
  }
98508
+ async function cmdBrand(argv) {
98509
+ const { flags } = parseArgs(argv);
98510
+ const json = !!flags.json;
98511
+ const FIELDS = [
98512
+ ["name", "Agency / your trading name"],
98513
+ ["email", "Contact email"],
98514
+ ["phone", "Contact phone"],
98515
+ ["website", "Website"],
98516
+ ["address", "Address (use \\n between lines)"],
98517
+ ["company-number", "Company number"],
98518
+ ["vat-number", "VAT number"],
98519
+ ["vat-rate", "VAT rate % (blank if not registered)"],
98520
+ ["bank-name", "Bank name"],
98521
+ ["bank-account", "Account number"],
98522
+ ["bank-sort-code", "Sort code"],
98523
+ ["payment-terms", "Payment terms line"],
98524
+ ["logo", "Path to a logo image (png/jpg)"],
98525
+ ["accent", "Accent colour hex, e.g. #556B45"]
98526
+ ];
98527
+ const toKey = (flag) => ({
98528
+ "company-number": "companyNumber",
98529
+ "vat-number": "vatNumber",
98530
+ "vat-rate": "vatRate",
98531
+ "bank-name": "bankName",
98532
+ "bank-account": "bankAccount",
98533
+ "bank-sort-code": "bankSortCode",
98534
+ "payment-terms": "paymentTerms",
98535
+ logo: "logoPath"
98536
+ })[flag] ?? flag;
98537
+ const current = getAgencyProfile();
98538
+ if (flags.show || argv.length === 0 || argv.length === 1 && json) {
98539
+ if (json) {
98540
+ emitJson({ ok: true, profile: current });
98541
+ return;
98542
+ }
98543
+ if (!current.name) {
98544
+ say();
98545
+ say(c.amber(" No agency details set yet."));
98546
+ say(c.dim(" These go on your invoices, letters and the sites you deliver."));
98547
+ say(c.dim(' Set them with: sproutkit brand --name "Northgate Web Studio" --email you@x.com'));
98548
+ say(c.dim(" Or run: sproutkit brand --interactive"));
98549
+ say();
98550
+ return;
98551
+ }
98552
+ say();
98553
+ say(c.bold(" Your agency details"));
98554
+ for (const [flag, label] of FIELDS) {
98555
+ const v = current[toKey(flag)];
98556
+ if (v) say(` ${label.padEnd(34)} ${String(v).replace(/\n/g, ", ")}`);
98557
+ }
98558
+ say();
98559
+ say(c.dim(' Change any of these with: sproutkit brand --<field> "value"'));
98560
+ say();
98561
+ return;
98562
+ }
98563
+ if (flags.interactive) {
98564
+ say();
98565
+ say(c.bold(" Your agency details"));
98566
+ say(c.dim(" These appear on invoices, letters and the sites you hand over."));
98567
+ say(c.dim(" Press enter to keep what's there / skip."));
98568
+ say();
98569
+ const patch2 = {};
98570
+ for (const [flag, label] of FIELDS) {
98571
+ const key = toKey(flag);
98572
+ const existing = current[key];
98573
+ const shown = existing ? c.dim(` [${String(existing).replace(/\n/g, ", ")}]`) : "";
98574
+ const answer = (await ask(` ${label}${shown}: `)).trim();
98575
+ if (!answer) continue;
98576
+ patch2[key] = key === "vatRate" ? Number(answer) || null : answer.replace(/\\n/g, "\n");
98577
+ }
98578
+ const saved2 = setAgencyProfile(patch2);
98579
+ say();
98580
+ say(c.green(` \u2713 Saved \u2014 invoices and sites will now say "${String(saved2.name ?? "")}"`));
98581
+ say();
98582
+ return;
98583
+ }
98584
+ const patch = {};
98585
+ for (const [flag] of FIELDS) {
98586
+ if (flags[flag] === void 0) continue;
98587
+ const key = toKey(flag);
98588
+ const raw = String(flags[flag]);
98589
+ patch[key] = key === "vatRate" ? Number(raw) || null : raw.replace(/\\n/g, "\n");
98590
+ }
98591
+ if (Object.keys(patch).length === 0) {
98592
+ const err = 'Nothing to set. Try: sproutkit brand --name "Your Studio" --email you@x.com, or --show';
98593
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98594
+ process.exitCode = 1;
98595
+ return;
98596
+ }
98597
+ const saved = setAgencyProfile(patch);
98598
+ if (json) {
98599
+ emitJson({ ok: true, profile: saved });
98600
+ return;
98601
+ }
98602
+ say();
98603
+ say(c.green(` \u2713 Updated ${Object.keys(patch).length} field(s).`));
98604
+ if (!saved.name) say(c.amber(" Still missing a name \u2014 invoices need one."));
98605
+ say();
98606
+ }
98607
+ async function cmdInvoice(argv) {
98608
+ const { flags, rest } = parseArgs(argv);
98609
+ const json = !!flags.json;
98610
+ const sub = rest[0];
98611
+ if (sub === "list") {
98612
+ const r = await runTool(
98613
+ "list_invoices",
98614
+ { status: flags.unpaid ? "issued" : flags.status },
98615
+ makeCtx({ json })
98616
+ );
98617
+ if (json) {
98618
+ emitJson(r);
98619
+ return;
98620
+ }
98621
+ if (!r.invoices.length) {
98622
+ say(c.dim(' No invoices yet. Create one: sproutkit invoice <leadId> --items "Website:1200"'));
98623
+ return;
98624
+ }
98625
+ say();
98626
+ say(" " + c.dim("NUMBER".padEnd(15) + "CLIENT".padEnd(26) + "TOTAL".padEnd(11) + "DUE".padEnd(12) + "STATUS"));
98627
+ say(" " + c.dim("\u2500".repeat(72)));
98628
+ for (const i of r.invoices) {
98629
+ const paid = i.status === "paid";
98630
+ say(
98631
+ " " + 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"))
98632
+ );
98633
+ }
98634
+ say();
98635
+ say(" " + c.dim("Outstanding: ") + c.bold(r.outstanding));
98636
+ say();
98637
+ return;
98638
+ }
98639
+ if (sub === "paid") {
98640
+ const number = rest[1] ?? flags.number;
98641
+ if (!number) {
98642
+ const err = "Usage: sproutkit invoice paid INV-2026-001";
98643
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98644
+ process.exitCode = 1;
98645
+ return;
98646
+ }
98647
+ const r = await runTool("mark_invoice_paid", { number }, makeCtx({ json }));
98648
+ json ? emitJson(r) : say(c.green(` \u2713 ${r.number} marked paid \u2014 ${r.client}, ${r.total}`));
98649
+ return;
98650
+ }
98651
+ const id = Number(flags.id ?? rest[0]);
98652
+ const items = flags.items ? String(flags.items) : "";
98653
+ if (!items) {
98654
+ 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>';
98655
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98656
+ process.exitCode = 1;
98657
+ return;
98658
+ }
98659
+ const status = json ? void 0 : new Status();
98660
+ if (status) status.start("Creating invoice");
98661
+ try {
98662
+ const r = await runTool(
98663
+ "create_invoice",
98664
+ {
98665
+ id: Number.isNaN(id) ? void 0 : id,
98666
+ clientName: flags.client ? String(flags.client) : void 0,
98667
+ items,
98668
+ dueInDays: flags.due ? Number(flags.due) : void 0,
98669
+ notes: flags.notes ? String(flags.notes) : void 0
98670
+ },
98671
+ makeCtx({ json, status })
98672
+ );
98673
+ if (r && r.ok === false) {
98674
+ if (status) status.done(false, r.error ?? "Couldn't create the invoice");
98675
+ if (json) emitJson(r);
98676
+ process.exitCode = 1;
98677
+ return;
98678
+ }
98679
+ if (status) status.done(true, `${r.number} \u2014 ${r.total}`);
98680
+ if (json) {
98681
+ emitJson(r);
98682
+ return;
98683
+ }
98684
+ say();
98685
+ say(` ${c.bold(String(r.number))} ${r.client}`);
98686
+ say(c.dim(` Subtotal ${r.subtotal}`) + (r.vat ? c.dim(` \xB7 VAT ${r.vat}`) : ""));
98687
+ say(` ${c.bold("Total " + r.total)}` + c.dim(` \xB7 due ${r.due}`));
98688
+ say(c.dim(` PDF: ${r.pdf}`));
98689
+ say();
98690
+ } catch (e) {
98691
+ if (status) status.done(false, "Invoice failed");
98692
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
98693
+ process.exitCode = 1;
98694
+ }
98695
+ }
98696
+ async function cmdLetter(argv) {
98697
+ const { flags, rest } = parseArgs(argv);
98698
+ const json = !!flags.json;
98699
+ const id = Number(flags.id ?? rest[0]);
98700
+ let body = flags.body ? String(flags.body) : "";
98701
+ if (!body && flags["body-file"]) {
98702
+ try {
98703
+ body = require("fs").readFileSync(String(flags["body-file"]), "utf8");
98704
+ } catch (e) {
98705
+ const err = `Couldn't read ${flags["body-file"]}: ${e.message}`;
98706
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98707
+ process.exitCode = 1;
98708
+ return;
98709
+ }
98710
+ }
98711
+ const to = flags.to ? String(flags.to) : "";
98712
+ if (!body || !to && Number.isNaN(id)) {
98713
+ const err = 'Usage: sproutkit letter <leadId> --subject "Your new website" --body "text"\n or: sproutkit letter --to "Name" --body-file letter.txt';
98714
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98715
+ process.exitCode = 1;
98716
+ return;
98717
+ }
98718
+ let recipient = to;
98719
+ let address = null;
98720
+ if (!recipient && !Number.isNaN(id)) {
98721
+ const lead = await runTool("get_lead", { id }, makeCtx({ json: true }));
98722
+ if (!lead?.name) {
98723
+ const err = `Lead #${id} not found`;
98724
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98725
+ process.exitCode = 1;
98726
+ return;
98727
+ }
98728
+ recipient = lead.name;
98729
+ address = lead.address ?? null;
98730
+ }
98731
+ try {
98732
+ const file = await renderLetterPdf({
98733
+ to: recipient,
98734
+ toAddress: address,
98735
+ subject: flags.subject ? String(flags.subject) : null,
98736
+ body
98737
+ });
98738
+ json ? emitJson({ ok: true, to: recipient, pdf: file }) : say(c.green(` \u2713 Letter for ${recipient}
98739
+ ${file}`));
98740
+ } catch (e) {
98741
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
98742
+ process.exitCode = 1;
98743
+ }
98744
+ }
98745
+ async function cmdReply(argv) {
98746
+ const { flags, rest } = parseArgs(argv);
98747
+ const json = !!flags.json;
98748
+ const id = Number(flags.id ?? rest[0]);
98749
+ 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;
98750
+ if (!id || Number.isNaN(id) || !outcome) {
98751
+ const err = 'Usage: sproutkit reply <leadId> --interested | --not-interested | --no-answer | --wrong-number | --callback\n optional: --note "wants to see menu options first"';
98752
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98753
+ process.exitCode = 1;
98754
+ return;
98755
+ }
98756
+ try {
98757
+ const r = await runTool(
98758
+ "log_reply",
98759
+ { id, outcome, note: flags.note ? String(flags.note) : void 0 },
98760
+ makeCtx({ json })
98761
+ );
98762
+ autoSync();
98763
+ if (json) {
98764
+ emitJson(r);
98765
+ return;
98766
+ }
98767
+ const nice = {
98768
+ interested: "interested",
98769
+ callback: "wants a callback",
98770
+ not_interested: "not interested",
98771
+ no_answer: "no answer",
98772
+ wrong_number: "wrong number"
98773
+ };
98774
+ say(c.green(` \u2713 ${r.business} \u2014 ${nice[String(r.outcome)] ?? r.outcome}`) + c.dim(` (now ${r.status})`));
98775
+ } catch (e) {
98776
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
98777
+ process.exitCode = 1;
98778
+ }
98779
+ }
98780
+ async function cmdFollowups(argv) {
98781
+ const { flags } = parseArgs(argv);
98782
+ const json = !!flags.json;
98783
+ const afterDays = flags.days ? Number(flags.days) : 3;
98784
+ const r = await runTool("list_followups", { afterDays }, makeCtx({ json }));
98785
+ if (json) {
98786
+ emitJson(r);
98787
+ return;
98788
+ }
98789
+ const s = r.stats;
98790
+ say();
98791
+ if (!r.due.length) {
98792
+ say(c.dim(` Nobody to chase \u2014 no unanswered outreach older than ${afterDays} day(s).`));
98793
+ } else {
98794
+ say(" " + c.dim("ID".padEnd(6) + "BUSINESS".padEnd(30) + "WAITING".padEnd(10) + "REACH THEM ON"));
98795
+ say(" " + c.dim("\u2500".repeat(74)));
98796
+ for (const l of r.due) {
98797
+ const reach = l.phone || l.email || c.dim("no contact");
98798
+ say(
98799
+ " " + c.dim(("#" + l.id).padEnd(6)) + c.bold(l.name.slice(0, 28).padEnd(30)) + c.amber((l.days_since + "d").padEnd(10)) + reach
98800
+ );
98801
+ }
98802
+ say();
98803
+ say(c.dim(" Log what happens with: sproutkit reply <id> --interested|--not-interested|--no-answer"));
98804
+ }
98805
+ say();
98806
+ say(
98807
+ " " + 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`)
98808
+ );
98809
+ say();
98810
+ }
98811
+ async function cmdReport(argv) {
98812
+ const { flags, rest } = parseArgs(argv);
98813
+ const json = !!flags.json;
98814
+ const id = Number(flags.id ?? rest[0]);
98815
+ if (!id || Number.isNaN(id)) {
98816
+ const err = "Usage: sproutkit report <leadId>";
98817
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
98818
+ process.exitCode = 1;
98819
+ return;
98820
+ }
98821
+ const status = json ? void 0 : new Status();
98822
+ if (status) status.start(`Reading numbers for #${id}`);
98823
+ try {
98824
+ const r = await runTool("site_report", { id }, makeCtx({ json, status }));
98825
+ if (r && r.ok === false) {
98826
+ if (status) status.done(false, r.error ?? "No stats");
98827
+ if (json) emitJson(r);
98828
+ process.exitCode = 1;
98829
+ return;
98830
+ }
98831
+ if (status) status.done(true, `${r.business}`);
98832
+ if (json) {
98833
+ emitJson(r);
98834
+ return;
98835
+ }
98836
+ const LABEL = {
98837
+ call: "Phone calls",
98838
+ book: "Booking clicks",
98839
+ view: "Page views",
98840
+ directions: "Directions",
98841
+ email: "Email clicks",
98842
+ menu: "Menu views"
98843
+ };
98844
+ say();
98845
+ say(` ${c.bold(String(r.business))}` + c.dim(` \xB7 ${r.url}`));
98846
+ say(c.dim(" Last 30 days vs the 30 before"));
98847
+ say();
98848
+ for (const k of ["call", "book", "view", "directions", "email", "menu"]) {
98849
+ const now = r.last30?.[k] ?? 0;
98850
+ const before = r.prev30?.[k] ?? 0;
98851
+ if (!now && !before) continue;
98852
+ let delta = "";
98853
+ if (before) {
98854
+ const pct = Math.round((now - before) / before * 100);
98855
+ delta = pct === 0 ? c.dim(" no change") : (pct > 0 ? c.green : c.amber)(` ${pct > 0 ? "+" : ""}${pct}%`);
98856
+ } else if (now) {
98857
+ delta = c.green(" new");
98858
+ }
98859
+ say(` ${LABEL[k].padEnd(16)} ${c.bold(String(now).padStart(5))}${delta}`);
98860
+ }
98861
+ const total = Object.values(r.allTime ?? {}).reduce((a, b) => a + b, 0);
98862
+ if (!total) {
98863
+ say(c.dim(" Nothing recorded yet \u2014 either it's very new, or it was built before analytics shipped."));
98864
+ }
98865
+ say();
98866
+ } catch (e) {
98867
+ if (status) status.done(false, "Report failed");
98868
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
98869
+ process.exitCode = 1;
98870
+ }
98871
+ }
97334
98872
  async function cmdRegenerate(argv) {
97335
98873
  const { flags, rest } = parseArgs(argv);
97336
98874
  const json = !!flags.json;
@@ -97602,6 +99140,13 @@ COMMANDS:
97602
99140
  - 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
97603
99141
  - 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)
97604
99142
  - sproutkit outreach --json [--ids 3,5] \u2014 prepare outreach links
99143
+ - sproutkit reply <id> --interested|--not-interested|--no-answer|--callback --json \u2014 log a response
99144
+ - sproutkit followups [--days 3] --json \u2014 who never replied + funnel stats
99145
+ - sproutkit report <id> --json \u2014 a live site's calls/bookings/views
99146
+ - sproutkit brand --show \u2014 the agency's own details (invoices, credits)
99147
+ - sproutkit invoice <id> --items "Website:1200,Booking:450" --json \u2014 PDF invoice on their letterhead
99148
+ - sproutkit invoice list [--unpaid] | invoice paid <number> \u2014 invoice ledger
99149
+ - sproutkit letter <id> --subject "..." --body "..." --json \u2014 letter/proposal on the letterhead
97605
99150
  - sproutkit pipeline x3 --json [--book] [--limit 10] \u2014 full pipeline (build\u2192booking\u2192outreach) for many clients concurrently
97606
99151
 
97607
99152
  If no command fits the request, say so in one line and name the closest thing you can do. Never invent results.`;
@@ -97785,7 +99330,7 @@ async function main() {
97785
99330
  closeTty();
97786
99331
  return;
97787
99332
  }
97788
- if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "handover" || arg === "outreach" || arg === "pipeline") {
99333
+ 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") {
97789
99334
  const subArgs = process.argv.slice(3);
97790
99335
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
97791
99336
  const USAGE = {
@@ -97825,6 +99370,41 @@ async function main() {
97825
99370
  ' --instructions "\u2026" e.g. "45-min slots, add beard trim \xA312"',
97826
99371
  " --email <addr> owner email for booking notifications"
97827
99372
  ],
99373
+ brand: [
99374
+ 'sproutkit brand --name "Your Studio" --email you@x.com [--vat-rate 20]',
99375
+ " your agency identity \u2014 goes on invoices, letters, the site footer",
99376
+ " credit, the demo banner and the /admin title",
99377
+ " --interactive walk through every field",
99378
+ " --show print what's currently set"
99379
+ ],
99380
+ invoice: [
99381
+ 'sproutkit invoice <leadId> --items "Website build:1200,Booking:450"',
99382
+ " renders a PDF invoice on your letterhead",
99383
+ ' quantities: --items "Monthly hosting:12x25"',
99384
+ " --due <days> payment terms (default 14)",
99385
+ " sproutkit invoice list [--unpaid]",
99386
+ " sproutkit invoice paid INV-2026-001"
99387
+ ],
99388
+ letter: [
99389
+ 'sproutkit letter <leadId> --subject "..." --body "..."',
99390
+ " a letter/proposal on the same letterhead",
99391
+ " --body-file <path> read the body from a file",
99392
+ ` --to "Name" write to someone who isn't a lead`
99393
+ ],
99394
+ reply: [
99395
+ "sproutkit reply <leadId> --interested | --not-interested | --no-answer",
99396
+ " log how a business responded, so follow-ups know who to chase",
99397
+ " --wrong-number | --callback",
99398
+ ' --note "wants menu options first"'
99399
+ ],
99400
+ followups: [
99401
+ "sproutkit followups [--days 3]",
99402
+ " who was contacted and never replied, plus your funnel stats"
99403
+ ],
99404
+ report: [
99405
+ "sproutkit report <leadId>",
99406
+ " a client site's calls/bookings/views, last 30 days vs the 30 before"
99407
+ ],
97828
99408
  handover: [
97829
99409
  "sproutkit handover <leadId> [--domain thesalon.co.uk]",
97830
99410
  " the paid-deliverable step: removes the DEMO banner, attaches the",
@@ -97867,6 +99447,12 @@ async function main() {
97867
99447
  else if (arg === "cms") await cmdCms(subArgs);
97868
99448
  else if (arg === "book") await cmdBook(subArgs);
97869
99449
  else if (arg === "handover") await cmdHandover(subArgs);
99450
+ else if (arg === "brand") await cmdBrand(subArgs);
99451
+ else if (arg === "invoice") await cmdInvoice(subArgs);
99452
+ else if (arg === "letter") await cmdLetter(subArgs);
99453
+ else if (arg === "reply") await cmdReply(subArgs);
99454
+ else if (arg === "followups") await cmdFollowups(subArgs);
99455
+ else if (arg === "report") await cmdReport(subArgs);
97870
99456
  else if (arg === "pipeline") await cmdPipeline(subArgs);
97871
99457
  else await cmdOutreach(subArgs);
97872
99458
  closeTty();