sproutkit-code 1.9.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +1437 -48
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -96,7 +96,8 @@ var require_runtime = __commonJS({
96
96
  "resend_key",
97
97
  "report_key",
98
98
  "smtp_pass",
99
- "imap_pass"
99
+ "imap_pass",
100
+ "stripe_secret_key"
100
101
  ]);
101
102
  function isSecretSetting(key) {
102
103
  return SECRET_SETTINGS.has(key);
@@ -314,6 +315,84 @@ var require_db = __commonJS({
314
315
  last_result TEXT,
315
316
  created_at TEXT DEFAULT (datetime('now'))
316
317
  );
318
+ `);
319
+ try {
320
+ db2.exec("ALTER TABLE schedules ADD COLUMN config TEXT");
321
+ } catch {
322
+ }
323
+ db2.exec(`
324
+ CREATE TABLE IF NOT EXISTS messages (
325
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
326
+ lead_id INTEGER NOT NULL,
327
+ direction TEXT NOT NULL,
328
+ from_addr TEXT,
329
+ to_addr TEXT,
330
+ subject TEXT,
331
+ body TEXT,
332
+ message_id TEXT UNIQUE,
333
+ in_reply_to TEXT,
334
+ at TEXT NOT NULL DEFAULT (datetime('now')),
335
+ intent TEXT,
336
+ intent_summary TEXT,
337
+ confidence REAL,
338
+ handled TEXT NOT NULL DEFAULT 'new',
339
+ draft_subject TEXT,
340
+ draft_body TEXT,
341
+ sent_by TEXT
342
+ );
343
+ `);
344
+ try {
345
+ db2.exec("CREATE INDEX IF NOT EXISTS idx_messages_lead ON messages(lead_id, at)");
346
+ } catch {
347
+ }
348
+ try {
349
+ db2.exec(`
350
+ INSERT INTO messages (lead_id, direction, from_addr, body, at, handled)
351
+ SELECT id, 'in', email, last_reply_snippet, COALESCE(last_reply_at, replied_at, datetime('now')), 'done'
352
+ FROM leads
353
+ WHERE last_reply_snippet IS NOT NULL AND last_reply_snippet != ''
354
+ AND NOT EXISTS (SELECT 1 FROM messages m WHERE m.lead_id = leads.id)
355
+ `);
356
+ } catch {
357
+ }
358
+ db2.exec(`
359
+ CREATE TABLE IF NOT EXISTS do_not_contact (
360
+ email TEXT PRIMARY KEY,
361
+ lead_id INTEGER,
362
+ reason TEXT,
363
+ at TEXT NOT NULL DEFAULT (datetime('now'))
364
+ );
365
+ `);
366
+ db2.exec(`
367
+ CREATE TABLE IF NOT EXISTS activity_log (
368
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
369
+ at TEXT NOT NULL DEFAULT (datetime('now')),
370
+ actor TEXT NOT NULL,
371
+ action TEXT NOT NULL,
372
+ lead_id INTEGER,
373
+ detail TEXT,
374
+ needs_you INTEGER NOT NULL DEFAULT 0
375
+ );
376
+ `);
377
+ db2.exec(`
378
+ CREATE TABLE IF NOT EXISTS deals (
379
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
380
+ lead_id INTEGER NOT NULL,
381
+ status TEXT NOT NULL DEFAULT 'open',
382
+ site_pence INTEGER NOT NULL,
383
+ care_pence INTEGER NOT NULL DEFAULT 0,
384
+ currency TEXT NOT NULL,
385
+ payment_link_id TEXT,
386
+ payment_url TEXT,
387
+ checkout_session_id TEXT,
388
+ stripe_customer_id TEXT,
389
+ subscription_id TEXT,
390
+ care_status TEXT,
391
+ invoice_number TEXT,
392
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
393
+ paid_at TEXT,
394
+ handed_over_at TEXT
395
+ );
317
396
  `);
318
397
  }
319
398
  function getDb2() {
@@ -30587,12 +30666,31 @@ var require_llm = __commonJS({
30587
30666
  backend: "api"
30588
30667
  };
30589
30668
  }
30669
+ var STALE_MODEL = /issue with the selected model|model .*(does not exist|not found|may not exist)|invalid model/i;
30670
+ var FALLBACK_ALIASES = ["sonnet", "opus", "haiku"];
30590
30671
  async function generateViaClaudeCode(args) {
30672
+ const preferred = getClaudeCodeModel2();
30673
+ try {
30674
+ return await runClaudeCode(args, preferred);
30675
+ } catch (e) {
30676
+ if (!STALE_MODEL.test(e.message))
30677
+ throw e;
30678
+ for (const alias of FALLBACK_ALIASES.filter((a) => a !== preferred)) {
30679
+ try {
30680
+ return await runClaudeCode(args, alias);
30681
+ } catch (e2) {
30682
+ if (!STALE_MODEL.test(e2.message))
30683
+ throw e2;
30684
+ }
30685
+ }
30686
+ throw e;
30687
+ }
30688
+ }
30689
+ async function runClaudeCode(args, alias) {
30591
30690
  if (args.images && args.images.length > 0) {
30592
30691
  throw new Error("Image input isn't supported on the Claude subscription backend. This step needs an Anthropic API key.");
30593
30692
  }
30594
30693
  const cliArgs = ["-p", "--output-format", "json"];
30595
- const alias = getClaudeCodeModel2();
30596
30694
  if (alias)
30597
30695
  cliArgs.push("--model", alias);
30598
30696
  if (args.allowWebSearch)
@@ -31721,6 +31819,8 @@ export default async function handler(req, res) {
31721
31819
  `;
31722
31820
  exports2.TRACK_SNIPPET = `<script>
31723
31821
  (function(){
31822
+ // The owner editing their own page in /edit isn't a visitor.
31823
+ if (location.search.indexOf("sk_edit=") > -1) return;
31724
31824
  function send(ev){
31725
31825
  try {
31726
31826
  var body = JSON.stringify({ event: ev });
@@ -33576,7 +33676,7 @@ var require_cms_runtime = __commonJS({
33576
33676
  };
33577
33677
  })();
33578
33678
  Object.defineProperty(exports2, "__esModule", { value: true });
33579
- exports2.ADMIN_APP_JS = exports2.ADMIN_INDEX_HTML = exports2.API_ADMIN_UPLOAD_JS = exports2.API_ADMIN_LOGOUT_JS = exports2.API_ADMIN_PASSWORD_JS = exports2.API_ADMIN_WHOAMI_JS = exports2.API_ADMIN_LOGIN_JS = exports2.API_CONTENT_JS = void 0;
33679
+ exports2.ADMIN_APP_JS = exports2.ADMIN_INDEX_HTML = exports2.EDIT_INDEX_HTML = exports2.API_ADMIN_UPLOAD_JS = exports2.API_ADMIN_LOGOUT_JS = exports2.API_ADMIN_PASSWORD_JS = exports2.API_ADMIN_WHOAMI_JS = exports2.API_ADMIN_LOGIN_JS = exports2.API_CONTENT_JS = void 0;
33580
33680
  exports2.generateFriendlyPassword = generateFriendlyPassword;
33581
33681
  exports2.hashPassword = hashPassword;
33582
33682
  exports2.generateSessionSecret = generateSessionSecret;
@@ -34283,6 +34383,548 @@ export default async function handler(req, res) {
34283
34383
  res.status(500).json({ error: "upload_failed", message: String(e) });
34284
34384
  }
34285
34385
  }
34386
+ `;
34387
+ exports2.EDIT_INDEX_HTML = String.raw`<!DOCTYPE html>
34388
+ <html lang="en">
34389
+ <head>
34390
+ <meta charset="UTF-8" />
34391
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
34392
+ <meta name="robots" content="noindex" />
34393
+ <meta name="apple-mobile-web-app-capable" content="yes" />
34394
+ <meta name="mobile-web-app-capable" content="yes" />
34395
+ <meta name="theme-color" content="#0a0a0a" />
34396
+ <title>__EDIT_TITLE__</title>
34397
+ <style>
34398
+ :root { --accent: #22c55e; --ink: #0a0a0a; --muted: #6b7280; --line: #e5e5e0; }
34399
+ * { box-sizing: border-box; }
34400
+ html, body { margin: 0; height: 100%; overflow: hidden; background: #f4f4f1; color: var(--ink);
34401
+ font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif; -webkit-text-size-adjust: 100%; }
34402
+ #site { position: fixed; top: 0; left: 0; width: 100%; border: 0; display: block; background: #fff;
34403
+ height: calc(100% - 64px - env(safe-area-inset-bottom)); }
34404
+ #bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 5; display: flex; align-items: center; gap: 8px;
34405
+ min-height: calc(64px + env(safe-area-inset-bottom)); padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
34406
+ background: #0a0a0a; color: #fff; }
34407
+ #status { flex: 1; min-width: 0; font-size: 13px; line-height: 1.3; }
34408
+ #status b { display: block; font-size: 14px; }
34409
+ #status span { display: block; color: #a1a1aa; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
34410
+ button { font: inherit; font-size: 14px; font-weight: 600; border: 0; border-radius: 10px; padding: 11px 16px;
34411
+ cursor: pointer; -webkit-tap-highlight-color: transparent; }
34412
+ .primary { background: var(--accent); color: #04130a; }
34413
+ .primary:disabled { opacity: 0.4; cursor: default; }
34414
+ .ghost { background: #27272a; color: #fff; }
34415
+ .icon { background: transparent; color: #fff; padding: 8px 10px; font-size: 20px; line-height: 1; }
34416
+ #menu { position: fixed; right: 10px; bottom: calc(72px + env(safe-area-inset-bottom)); z-index: 6; display: none;
34417
+ min-width: 230px; padding: 6px; background: #fff; border: 1px solid var(--line); border-radius: 12px;
34418
+ box-shadow: 0 12px 32px rgba(0,0,0,0.18); }
34419
+ #menu a, #menu button { display: block; width: 100%; padding: 12px 14px; border-radius: 8px; background: transparent;
34420
+ color: var(--ink); text-align: left; text-decoration: none; font-size: 14px; font-weight: 500; }
34421
+ #menu a:hover, #menu button:hover { background: #f4f4f1; }
34422
+ #sheet { position: fixed; inset: 0; z-index: 10; display: none; align-items: center; justify-content: center;
34423
+ padding: 16px; background: rgba(10,10,10,0.55); }
34424
+ #sheet form { width: 100%; max-width: 360px; padding: 24px; background: #fff; border-radius: 16px; }
34425
+ #sheet h1 { margin: 0 0 4px; font-size: 20px; letter-spacing: -0.01em; }
34426
+ #sheet p { margin: 0 0 16px; color: var(--muted); font-size: 14px; }
34427
+ #sheet input { width: 100%; margin-bottom: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 10px;
34428
+ font: inherit; font-size: 16px; }
34429
+ #sheet button { width: 100%; }
34430
+ #err { min-height: 1em; margin-top: 10px; color: #b91c1c; font-size: 13px; }
34431
+ #toast { position: fixed; left: 50%; bottom: calc(76px + env(safe-area-inset-bottom)); z-index: 7;
34432
+ max-width: calc(100% - 32px); transform: translateX(-50%); padding: 10px 16px; border-radius: 999px;
34433
+ background: #fff; color: var(--ink); border: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0,0,0,0.15);
34434
+ font-size: 13px; font-weight: 500; text-align: center; opacity: 0; transition: opacity 0.2s; pointer-events: none; }
34435
+ #toast.on { opacity: 1; }
34436
+ </style>
34437
+ </head>
34438
+ <body>
34439
+ <iframe id="site" title="Your website"></iframe>
34440
+ <div id="bar">
34441
+ <div id="status"><b>Edit mode</b><span>Loading your site…</span></div>
34442
+ <button id="discard" class="ghost" hidden>Undo</button>
34443
+ <button id="save" class="primary" disabled>Save</button>
34444
+ <button id="more" class="icon" aria-label="More options">&#8943;</button>
34445
+ </div>
34446
+ <div id="menu">
34447
+ <a href="/admin">All settings &mdash; services, hours, photos</a>
34448
+ <a href="/" target="_blank" rel="noopener">View live site</a>
34449
+ <button id="signout" type="button">Sign out</button>
34450
+ </div>
34451
+ <div id="sheet">
34452
+ <form id="login">
34453
+ <h1>Edit your website</h1>
34454
+ <p>Enter your password to start editing.</p>
34455
+ <input id="pw" type="password" autocomplete="current-password" placeholder="Password" />
34456
+ <button class="primary" type="submit" id="go">Sign in</button>
34457
+ <div id="err"></div>
34458
+ </form>
34459
+ </div>
34460
+ <input id="file" type="file" accept="image/*" hidden />
34461
+ <div id="toast"></div>
34462
+ <script src="https://unpkg.com/browser-image-compression@2.0.2/dist/browser-image-compression.js" crossorigin></script>
34463
+ <script>
34464
+ (function () {
34465
+ var $ = function (id) { return document.getElementById(id); };
34466
+ var frame = $("site"), saveBtn = $("save"), discardBtn = $("discard"), statusEl = $("status");
34467
+ var menu = $("menu"), sheet = $("sheet"), pw = $("pw"), err = $("err"), fileInput = $("file");
34468
+
34469
+ // Fields the owner can change in place. Reviews come live from Google and
34470
+ // the theme is a design choice, so neither is offered here.
34471
+ var TEXT_PATHS = /^(business\.(name|tagline)|hero\.(headline|subheadline|ctaText)|about|services\.\d+\.(title|description|price)|contact\.(address|phone|email)|contact\.hours\.\d+)$/;
34472
+ var MULTILINE = /^(about|hero\.subheadline|services\.\d+\.description)$/;
34473
+ var HTML_NS = "http://www.w3.org/1999/xhtml";
34474
+
34475
+ var base = null; // content as last saved
34476
+ var draft = null; // content including unsaved edits
34477
+ var dirty = {}; // path -> true while it differs from base
34478
+ var editing = null, saving = false, loginResolve = null, photoIdx = null;
34479
+ var chipFor = new Map(), observer = null, scanTimer = null;
34480
+
34481
+ function norm(s) { return String(s == null ? "" : s).replace(/\s+/g, " ").trim(); }
34482
+ function clone(o) { return JSON.parse(JSON.stringify(o)); }
34483
+ function get(obj, path) {
34484
+ return path.split(".").reduce(function (o, k) { return o == null ? undefined : o[k]; }, obj);
34485
+ }
34486
+ function put(obj, path, v) {
34487
+ var ks = path.split("."), o = obj;
34488
+ for (var i = 0; i < ks.length - 1; i++) { o = o[ks[i]]; if (o == null) return; }
34489
+ o[ks[ks.length - 1]] = v;
34490
+ }
34491
+ function leaves(obj, prefix, out) {
34492
+ if (typeof obj === "string") { if (TEXT_PATHS.test(prefix)) out.push(prefix); }
34493
+ else if (obj && typeof obj === "object") {
34494
+ Object.keys(obj).forEach(function (k) { leaves(obj[k], prefix ? prefix + "." + k : k, out); });
34495
+ }
34496
+ return out;
34497
+ }
34498
+
34499
+ var toastTimer = null;
34500
+ function toast(msg, ms) {
34501
+ var t = $("toast");
34502
+ t.textContent = msg; t.classList.add("on");
34503
+ clearTimeout(toastTimer);
34504
+ toastTimer = setTimeout(function () { t.classList.remove("on"); }, ms || 2600);
34505
+ }
34506
+
34507
+ function updateBar() {
34508
+ var n = Object.keys(dirty).length;
34509
+ saveBtn.disabled = n === 0 || saving;
34510
+ saveBtn.textContent = saving ? "Saving…" : "Save";
34511
+ discardBtn.hidden = n === 0 || saving;
34512
+ statusEl.innerHTML = n
34513
+ ? "<b>" + n + " unsaved change" + (n > 1 ? "s" : "") + "</b><span>Press Save to put them live</span>"
34514
+ : "<b>Edit mode</b><span>Tap any outlined text or photo to change it</span>";
34515
+ }
34516
+
34517
+ function refreshDirty(paths) {
34518
+ paths.forEach(function (p) {
34519
+ var a = get(draft, p), b = get(base, p);
34520
+ if (p.indexOf("photos.") === 0 ? a !== b : norm(a) !== norm(b)) dirty[p] = true;
34521
+ else delete dirty[p];
34522
+ });
34523
+ updateBar();
34524
+ }
34525
+
34526
+ // ---- Login ----------------------------------------------------------------
34527
+ function requireLogin() {
34528
+ return new Promise(function (resolve) {
34529
+ loginResolve = resolve;
34530
+ err.textContent = "";
34531
+ sheet.style.display = "flex";
34532
+ setTimeout(function () { pw.focus(); }, 50);
34533
+ });
34534
+ }
34535
+ $("login").addEventListener("submit", async function (e) {
34536
+ e.preventDefault();
34537
+ if (!pw.value) return;
34538
+ $("go").disabled = true; err.textContent = "";
34539
+ try {
34540
+ var r = await fetch("/api/admin/login", {
34541
+ method: "POST", credentials: "same-origin",
34542
+ headers: { "Content-Type": "application/json" },
34543
+ body: JSON.stringify({ password: pw.value })
34544
+ });
34545
+ if (r.status === 429) {
34546
+ var lock = await r.json().catch(function () { return {}; });
34547
+ err.textContent = "Too many tries. Wait " + Math.ceil((lock.retryAfter || 900) / 60) + " minutes and try again.";
34548
+ } else if (!r.ok) {
34549
+ err.textContent = r.status === 401 ? "That password didn't work." : "Couldn't sign in (" + r.status + ").";
34550
+ } else {
34551
+ var who = await fetch("/api/admin/whoami", { credentials: "same-origin" });
34552
+ if (!who.ok) {
34553
+ err.textContent = "Signed in, but your browser blocked the login cookie. Turn off strict tracking protection for this site.";
34554
+ } else {
34555
+ pw.value = ""; sheet.style.display = "none";
34556
+ if (loginResolve) { loginResolve(); loginResolve = null; }
34557
+ }
34558
+ }
34559
+ } catch (x) {
34560
+ err.textContent = "No connection. Check your signal and try again.";
34561
+ }
34562
+ $("go").disabled = false;
34563
+ });
34564
+
34565
+ // ---- Matching the page to content.json ------------------------------------
34566
+ // A field may render as one element or, when it has blank-line breaks, one
34567
+ // element per paragraph. "about#1" means the second paragraph of about.
34568
+ function paras(v) { return String(v == null ? "" : v).split(/\n\s*\n/); }
34569
+ function readField(obj, ref) {
34570
+ var at = ref.indexOf("#");
34571
+ if (at < 0) return get(obj, ref);
34572
+ return paras(get(obj, ref.slice(0, at)))[Number(ref.slice(at + 1))];
34573
+ }
34574
+ function writeField(obj, ref, v) {
34575
+ var at = ref.indexOf("#");
34576
+ if (at < 0) return put(obj, ref, v);
34577
+ var path = ref.slice(0, at), list = paras(get(obj, path));
34578
+ list[Number(ref.slice(at + 1))] = v;
34579
+ put(obj, path, list.join("\n\n"));
34580
+ }
34581
+ function fieldOf(ref) { var at = ref.indexOf("#"); return at < 0 ? ref : ref.slice(0, at); }
34582
+
34583
+ function textIndex() {
34584
+ // Saved values too, so a page that still shows old text (a second page of
34585
+ // a multipage site, say) is still recognised — then shown with the draft.
34586
+ var map = {};
34587
+ function add(t, ref) {
34588
+ t = norm(t);
34589
+ if (t.length < 2) return;
34590
+ map[t] = map[t] || [];
34591
+ if (map[t].indexOf(ref) < 0) map[t].push(ref);
34592
+ }
34593
+ leaves(draft, "", []).forEach(function (p) {
34594
+ [draft, base].forEach(function (src) {
34595
+ var v = get(src, p);
34596
+ add(v, p);
34597
+ var list = paras(v);
34598
+ if (list.length > 1) list.forEach(function (para, i) { add(para, p + "#" + i); });
34599
+ });
34600
+ });
34601
+ return map;
34602
+ }
34603
+
34604
+ // Text, line breaks and inline styling (a coloured word in a headline) are
34605
+ // fine to edit in place; anything structural is not.
34606
+ var INLINE = /^(BR|EM|STRONG|B|I|U|SPAN|SMALL|MARK|SUP|SUB)$/;
34607
+ function editableShape(el, text) {
34608
+ for (var i = 0; i < el.children.length; i++) {
34609
+ var c = el.children[i];
34610
+ if (!INLINE.test(c.tagName) || c.children.length > 0) return false;
34611
+ if (norm(c.textContent) === text) return false; // the child is the better match
34612
+ }
34613
+ return true;
34614
+ }
34615
+
34616
+ function injectStyle(doc) {
34617
+ if (doc.getElementById("sk-edit-style")) return;
34618
+ var s = doc.createElement("style");
34619
+ s.id = "sk-edit-style";
34620
+ s.textContent =
34621
+ "[data-sk-path]{outline:1.5px dashed rgba(34,197,94,.9)!important;outline-offset:3px;cursor:text!important;border-radius:2px;-webkit-user-select:text;user-select:text}" +
34622
+ "[data-sk-path].sk-changed{outline-color:#f59e0b!important}" +
34623
+ "[data-sk-path][contenteditable]{outline:2px solid #22c55e!important;background:rgba(34,197,94,.12)!important}" +
34624
+ ".sk-chip{position:absolute;z-index:2147483646;padding:9px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.3);background:rgba(10,10,10,.85);color:#fff;font:600 12px/1 Inter,system-ui,sans-serif;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.3)}";
34625
+ doc.head.appendChild(s);
34626
+ }
34627
+
34628
+ function scan() {
34629
+ var doc = frame.contentDocument;
34630
+ if (!doc || !doc.body || !draft || editing) return;
34631
+ injectStyle(doc);
34632
+ var root = doc.getElementById("root") || doc.body;
34633
+ var map = textIndex();
34634
+ var els = root.querySelectorAll("*");
34635
+ for (var i = 0; i < els.length; i++) {
34636
+ var el = els[i];
34637
+ if (el.namespaceURI !== HTML_NS || /^(SCRIPT|STYLE|NOSCRIPT|IFRAME|INPUT|TEXTAREA|SELECT|OPTION)$/.test(el.tagName)) continue;
34638
+ var text = norm(el.textContent);
34639
+ var paths = map[text] && editableShape(el, text) ? map[text] : null;
34640
+ if (paths) {
34641
+ el.setAttribute("data-sk-path", paths.join("|"));
34642
+ var want = readField(draft, paths[0]);
34643
+ if (norm(want) !== text) el.textContent = want;
34644
+ el.classList.toggle("sk-changed", paths.some(function (p) { return dirty[fieldOf(p)]; }));
34645
+ } else if (el.hasAttribute("data-sk-path")) {
34646
+ el.removeAttribute("data-sk-path");
34647
+ el.classList.remove("sk-changed");
34648
+ }
34649
+ }
34650
+ placeChips(doc);
34651
+ }
34652
+
34653
+ function scheduleScan() {
34654
+ clearTimeout(scanTimer);
34655
+ scanTimer = setTimeout(scan, 250);
34656
+ }
34657
+
34658
+ // ---- Photos ---------------------------------------------------------------
34659
+ function abs(u, doc) { try { return new URL(u, doc.baseURI).href; } catch (x) { return u; } }
34660
+ function shownUrl(el, win) {
34661
+ if (el.tagName === "IMG") return el.currentSrc || el.src;
34662
+ var m = (win.getComputedStyle(el).backgroundImage || "").match(/url\(["']?(.*?)["']?\)/);
34663
+ return m ? m[1] : null;
34664
+ }
34665
+ function showPhoto(el, url) {
34666
+ if (el.tagName === "IMG") { el.removeAttribute("srcset"); el.src = url; return; }
34667
+ var cur = el.style.backgroundImage;
34668
+ el.style.backgroundImage = /url\(/.test(cur)
34669
+ ? cur.replace(/url\((["']?)(.*?)\1\)/, 'url("' + url + '")')
34670
+ : 'url("' + url + '")';
34671
+ }
34672
+
34673
+ function placeChips(doc) {
34674
+ var win = frame.contentWindow;
34675
+ var known = {};
34676
+ (base.photos || []).forEach(function (p, i) { if (p && p.url) known[abs(p.url, doc)] = i; });
34677
+ (draft.photos || []).forEach(function (p, i) { if (p && p.url) known[abs(p.url, doc)] = i; });
34678
+ var seen = new Set();
34679
+ var els = doc.querySelectorAll('#root img, #root [style*="url("]');
34680
+ for (var i = 0; i < els.length; i++) {
34681
+ var el = els[i], u = shownUrl(el, win);
34682
+ var idx = u ? known[abs(u, doc)] : undefined;
34683
+ if (idx === undefined) continue;
34684
+ var want = draft.photos[idx] && draft.photos[idx].url;
34685
+ if (want && abs(want, doc) !== abs(u, doc)) showPhoto(el, want);
34686
+ var r = el.getBoundingClientRect(), cs = win.getComputedStyle(el);
34687
+ var chip = chipFor.get(el);
34688
+ if (r.width < 80 || r.height < 60 || cs.visibility === "hidden" || Number(cs.opacity) < 0.1) {
34689
+ if (chip) chip.style.display = "none";
34690
+ seen.add(el);
34691
+ continue;
34692
+ }
34693
+ if (!chip) {
34694
+ chip = doc.createElement("button");
34695
+ chip.type = "button";
34696
+ chip.className = "sk-chip";
34697
+ chip.setAttribute("data-sk-ui", "1");
34698
+ chip.textContent = "Change photo";
34699
+ chip.addEventListener("click", function (e) {
34700
+ e.preventDefault(); e.stopPropagation();
34701
+ photoIdx = Number(e.currentTarget.getAttribute("data-idx"));
34702
+ fileInput.value = "";
34703
+ fileInput.click();
34704
+ });
34705
+ doc.body.appendChild(chip);
34706
+ chipFor.set(el, chip);
34707
+ }
34708
+ chip.setAttribute("data-idx", String(idx));
34709
+ chip.style.display = "";
34710
+ chip.style.top = (r.top + win.scrollY + 10) + "px";
34711
+ chip.style.left = (r.left + win.scrollX + 10) + "px";
34712
+ seen.add(el);
34713
+ }
34714
+ chipFor.forEach(function (chip, el) {
34715
+ if (!seen.has(el)) { chip.remove(); chipFor.delete(el); }
34716
+ });
34717
+ }
34718
+
34719
+ fileInput.addEventListener("change", async function () {
34720
+ var f = fileInput.files && fileInput.files[0];
34721
+ if (!f || photoIdx === null) return;
34722
+ var idx = photoIdx;
34723
+ toast("Uploading photo…", 60000);
34724
+ try {
34725
+ var blob = f;
34726
+ if (window.imageCompression) {
34727
+ blob = await imageCompression(f, { maxSizeMB: 1.5, maxWidthOrHeight: 1600, fileType: "image/webp", useWebWorker: true });
34728
+ }
34729
+ var send = function () {
34730
+ return fetch("/api/admin/upload", {
34731
+ method: "POST", credentials: "same-origin",
34732
+ headers: { "Content-Type": blob.type || "image/webp" }, body: blob
34733
+ });
34734
+ };
34735
+ var r = await send();
34736
+ if (r.status === 401) { await requireLogin(); r = await send(); }
34737
+ if (r.status === 413) throw new Error("that photo is too big");
34738
+ if (!r.ok) throw new Error("upload failed (" + r.status + ")");
34739
+ var data = await r.json();
34740
+ draft.photos[idx] = { url: data.url, alt: (draft.photos[idx] && draft.photos[idx].alt) || "" };
34741
+ refreshDirty(["photos." + idx]);
34742
+ scan();
34743
+ toast("Photo changed. Press Save to put it live.");
34744
+ } catch (x) {
34745
+ toast("Couldn't change the photo: " + (x && x.message ? x.message : x), 4000);
34746
+ }
34747
+ });
34748
+
34749
+ // ---- Editing text ---------------------------------------------------------
34750
+ function pathsOf(el) { return (el.getAttribute("data-sk-path") || "").split("|").filter(Boolean); }
34751
+
34752
+ function startEdit(el) {
34753
+ if (editing === el) return;
34754
+ if (editing) editing.blur();
34755
+ editing = el;
34756
+ try { el.contentEditable = "plaintext-only"; } catch (x) { /* older browsers */ }
34757
+ if (el.contentEditable !== "plaintext-only") el.contentEditable = "true";
34758
+ el.spellcheck = true;
34759
+ el.addEventListener("input", onInput);
34760
+ el.addEventListener("keydown", onKey);
34761
+ el.addEventListener("paste", onPaste);
34762
+ el.addEventListener("blur", onBlur);
34763
+ el.focus();
34764
+ var doc = el.ownerDocument, sel = doc.getSelection(), range = doc.createRange();
34765
+ range.selectNodeContents(el); range.collapse(false);
34766
+ sel.removeAllRanges(); sel.addRange(range);
34767
+ }
34768
+
34769
+ function onInput(e) {
34770
+ var el = e.currentTarget, paths = pathsOf(el);
34771
+ var v = el.innerText.replace(/\u00a0/g, " ");
34772
+ // A single paragraph can't hold a paragraph break — it would split on reload.
34773
+ if (!MULTILINE.test(paths[0]) || paths[0].indexOf("#") > -1) v = v.replace(/\s*\n\s*/g, " ");
34774
+ v = v.trim();
34775
+ paths.forEach(function (p) { writeField(draft, p, v); });
34776
+ // The same text shown elsewhere on the page (name in the nav and footer).
34777
+ var others = el.ownerDocument.querySelectorAll("[data-sk-path]");
34778
+ for (var i = 0; i < others.length; i++) {
34779
+ if (others[i] !== el && others[i].getAttribute("data-sk-path") === el.getAttribute("data-sk-path")) others[i].textContent = v;
34780
+ }
34781
+ refreshDirty(paths.map(fieldOf));
34782
+ }
34783
+
34784
+ function onKey(e) {
34785
+ var el = e.currentTarget;
34786
+ var ref = pathsOf(el)[0];
34787
+ if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey && (!MULTILINE.test(ref) || ref.indexOf("#") > -1))) {
34788
+ e.preventDefault();
34789
+ el.blur();
34790
+ }
34791
+ }
34792
+
34793
+ function onPaste(e) {
34794
+ if (e.currentTarget.contentEditable === "plaintext-only") return;
34795
+ e.preventDefault();
34796
+ var text = (e.clipboardData || window.clipboardData).getData("text/plain");
34797
+ e.currentTarget.ownerDocument.execCommand("insertText", false, text);
34798
+ }
34799
+
34800
+ function onBlur(e) {
34801
+ var el = e.currentTarget;
34802
+ el.removeAttribute("contenteditable");
34803
+ el.removeEventListener("input", onInput);
34804
+ el.removeEventListener("keydown", onKey);
34805
+ el.removeEventListener("paste", onPaste);
34806
+ el.removeEventListener("blur", onBlur);
34807
+ el.classList.toggle("sk-changed", pathsOf(el).some(function (p) { return dirty[fieldOf(p)]; }));
34808
+ editing = null;
34809
+ scheduleScan();
34810
+ }
34811
+
34812
+ // ---- The site frame -------------------------------------------------------
34813
+ function onFrameClick(e) {
34814
+ var t = e.target;
34815
+ if (t && t.nodeType !== 1) t = t.parentElement;
34816
+ if (!t || !t.closest || t.closest("[data-sk-ui]")) return;
34817
+ var field = t.closest("[data-sk-path]");
34818
+ if (field) {
34819
+ // Also stops the site's own handler, so tapping a button's label edits
34820
+ // the label instead of pressing the button.
34821
+ e.preventDefault(); e.stopPropagation();
34822
+ startEdit(field);
34823
+ return;
34824
+ }
34825
+ var a = t.closest("a[href]");
34826
+ if (!a) return;
34827
+ var url = null;
34828
+ try { url = new URL(a.getAttribute("href"), a.ownerDocument.baseURI); } catch (x) {}
34829
+ var samePage = url && url.origin === location.origin && !a.target && !/^\/(admin|edit|api|book)(\/|$)/.test(url.pathname);
34830
+ if (samePage) return; // moving between the site's own pages is fine
34831
+ e.preventDefault(); e.stopPropagation();
34832
+ toast("Links don't open while you're editing");
34833
+ }
34834
+
34835
+ function onFrameLoad() {
34836
+ var doc = frame.contentDocument;
34837
+ if (!doc || !doc.body) return;
34838
+ chipFor = new Map();
34839
+ editing = null;
34840
+ doc.addEventListener("click", onFrameClick, true);
34841
+ doc.addEventListener("submit", function (e) {
34842
+ e.preventDefault(); e.stopPropagation();
34843
+ toast("Forms don't send while you're editing");
34844
+ }, true);
34845
+ if (observer) observer.disconnect();
34846
+ observer = new MutationObserver(scheduleScan);
34847
+ observer.observe(doc.getElementById("root") || doc.body, {
34848
+ childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["src", "style"]
34849
+ });
34850
+ frame.contentWindow.addEventListener("resize", scheduleScan);
34851
+ scan();
34852
+ }
34853
+
34854
+ // ---- Save / undo ----------------------------------------------------------
34855
+ async function save() {
34856
+ if (editing) editing.blur();
34857
+ if (!Object.keys(dirty).length) return;
34858
+ saving = true; updateBar();
34859
+ try {
34860
+ var send = function () {
34861
+ return fetch("/api/content", {
34862
+ method: "POST", credentials: "same-origin",
34863
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify(draft)
34864
+ });
34865
+ };
34866
+ var r = await send();
34867
+ if (r.status === 401) { await requireLogin(); r = await send(); }
34868
+ if (!r.ok) throw new Error("the server said " + r.status);
34869
+ var d = await r.json();
34870
+ base = d.content || clone(draft);
34871
+ draft = clone(base);
34872
+ dirty = {};
34873
+ toast("Saved. Visitors will see it within a minute.", 3500);
34874
+ } catch (x) {
34875
+ toast("Couldn't save: " + (x && x.message ? x.message : x) + ". Your changes are still here.", 5000);
34876
+ }
34877
+ saving = false;
34878
+ updateBar();
34879
+ scan();
34880
+ }
34881
+
34882
+ saveBtn.addEventListener("click", save);
34883
+ discardBtn.addEventListener("click", function () {
34884
+ if (!confirm("Undo all unsaved changes?")) return;
34885
+ draft = clone(base); dirty = {};
34886
+ updateBar();
34887
+ frame.contentWindow.location.reload();
34888
+ });
34889
+ $("more").addEventListener("click", function (e) {
34890
+ e.stopPropagation();
34891
+ menu.style.display = menu.style.display === "block" ? "none" : "block";
34892
+ });
34893
+ document.addEventListener("click", function () { menu.style.display = "none"; });
34894
+ $("signout").addEventListener("click", async function () {
34895
+ if (Object.keys(dirty).length && !confirm("You have unsaved changes. Sign out anyway?")) return;
34896
+ dirty = {};
34897
+ await fetch("/api/admin/logout", { method: "POST", credentials: "same-origin" }).catch(function () {});
34898
+ location.href = "/";
34899
+ });
34900
+ window.addEventListener("beforeunload", function (e) {
34901
+ if (Object.keys(dirty).length) { e.preventDefault(); e.returnValue = ""; }
34902
+ });
34903
+
34904
+ async function init() {
34905
+ frame.addEventListener("load", onFrameLoad);
34906
+ frame.src = "/?sk_edit=1";
34907
+ var who = await fetch("/api/admin/whoami", { credentials: "same-origin" }).catch(function () { return null; });
34908
+ if (!who || !who.ok) await requireLogin();
34909
+ var r = await fetch("/api/content?t=" + Date.now(), { cache: "no-store" }).catch(function () { return null; });
34910
+ if (!r || !r.ok) {
34911
+ statusEl.innerHTML = "<b>Editing isn't switched on</b><span>Ask whoever built your site to turn on the editor.</span>";
34912
+ return;
34913
+ }
34914
+ base = await r.json();
34915
+ if (!Array.isArray(base.photos)) base.photos = [];
34916
+ draft = clone(base);
34917
+ updateBar();
34918
+ scan();
34919
+ // Layout settles as fonts and images load; keep the photo chips in place.
34920
+ setInterval(function () { if (!editing) scan(); }, 1500);
34921
+ toast("Tap anything with a dashed outline to change it", 4000);
34922
+ }
34923
+ init();
34924
+ })();
34925
+ </script>
34926
+ </body>
34927
+ </html>
34286
34928
  `;
34287
34929
  exports2.ADMIN_INDEX_HTML = `<!DOCTYPE html>
34288
34930
  <html lang="en">
@@ -34522,7 +35164,10 @@ export default async function handler(req, res) {
34522
35164
  h("div", { className: "bar" },
34523
35165
  h("div", null,
34524
35166
  h("div", { style: { fontSize: 15, fontWeight: 600 } }, "Editing " + (content.business?.name || "your site")),
34525
- h("div", { style: { fontSize: 12, color: "var(--muted)" } }, h("a", { href: "/" }, "View site \u2192"))
35167
+ h("div", { style: { fontSize: 12, color: "var(--muted)" } },
35168
+ h("a", { href: "/" }, "View site \u2192"), " \xB7 ",
35169
+ h("a", { href: "/edit" }, "Edit on the page \u2192")
35170
+ )
34526
35171
  ),
34527
35172
  h("button", { className: "primary", onClick: save, disabled: saving[0] }, saving[0] ? "Saving\u2026" : "Save changes")
34528
35173
  ),
@@ -80955,6 +81600,7 @@ var require_builder = __commonJS({
80955
81600
  exports2.deployBookingOnly = deployBookingOnly2;
80956
81601
  exports2.deployStandaloneBooking = deployStandaloneBooking2;
80957
81602
  exports2.handoverSite = handoverSite2;
81603
+ exports2.addEditorToSite = addEditorToSite2;
80958
81604
  var sdk_1 = __importDefault(require_sdk());
80959
81605
  var axios_1 = __importDefault(require_axios());
80960
81606
  var crypto_1 = require("crypto");
@@ -81066,6 +81712,11 @@ var require_builder = __commonJS({
81066
81712
  const title = p.name ? `${p.name} \u2014 site manager` : "Site editor";
81067
81713
  return cms_runtime_1.ADMIN_INDEX_HTML.replace("__ADMIN_TITLE__", title.replace(/[<>&]/g, ""));
81068
81714
  }
81715
+ function editHtml() {
81716
+ const p = (0, agency_1.getAgencyProfile)();
81717
+ const title = p.name ? `Edit your site \u2014 ${p.name}` : "Edit your site";
81718
+ return cms_runtime_1.EDIT_INDEX_HTML.replace("__EDIT_TITLE__", title.replace(/[<>&]/g, ""));
81719
+ }
81069
81720
  function bannerFor(lead) {
81070
81721
  let handedOver = false;
81071
81722
  try {
@@ -81763,7 +82414,9 @@ export default defineConfig({ plugins: [react()] });`;
81763
82414
  const vercelJson = JSON.stringify({
81764
82415
  rewrites: [
81765
82416
  { source: "/admin", destination: "/admin/index.html" },
81766
- { source: "/admin/", destination: "/admin/index.html" }
82417
+ { source: "/admin/", destination: "/admin/index.html" },
82418
+ { source: "/edit", destination: "/edit/index.html" },
82419
+ { source: "/edit/", destination: "/edit/index.html" }
81767
82420
  ]
81768
82421
  });
81769
82422
  cmsExtraFiles = [
@@ -81775,7 +82428,8 @@ export default defineConfig({ plugins: [react()] });`;
81775
82428
  { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
81776
82429
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" },
81777
82430
  { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
81778
- { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" }
82431
+ { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" },
82432
+ { file: "public/edit/index.html", data: editHtml(), encoding: "utf-8" }
81779
82433
  ];
81780
82434
  cmsAdmin = { url: "", password: adminPassword };
81781
82435
  }
@@ -81875,6 +82529,8 @@ export default defineConfig({ plugins: [react()] });`;
81875
82529
  rewrites: [
81876
82530
  { source: "/admin", destination: "/admin/index.html" },
81877
82531
  { source: "/admin/", destination: "/admin/index.html" },
82532
+ { source: "/edit", destination: "/edit/index.html" },
82533
+ { source: "/edit/", destination: "/edit/index.html" },
81878
82534
  { source: "/book", destination: "/book/index.html" },
81879
82535
  { source: "/book/", destination: "/book/index.html" }
81880
82536
  ]
@@ -81887,7 +82543,7 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
81887
82543
  const viteConfigCms = `import { defineConfig } from 'vite';
81888
82544
  import react from '@vitejs/plugin-react';
81889
82545
  export default defineConfig({ plugins: [react()] });`;
81890
- 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/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_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" });
82546
+ 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/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_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" }, { file: "public/edit/index.html", data: editHtml(), encoding: "utf-8" });
81891
82547
  if (!filesToRedeploy.some((f) => f.file === "src/App.jsx")) {
81892
82548
  throw new Error("Couldn't fetch src/App.jsx from the existing deployment. Try Regenerate website first, then Redeploy CMS.");
81893
82549
  }
@@ -82023,6 +82679,8 @@ export default defineConfig({ plugins: [react()] });`;
82023
82679
  rewrites: [
82024
82680
  { source: "/admin", destination: "/admin/index.html" },
82025
82681
  { source: "/admin/", destination: "/admin/index.html" },
82682
+ { source: "/edit", destination: "/edit/index.html" },
82683
+ { source: "/edit/", destination: "/edit/index.html" },
82026
82684
  { source: "/book", destination: "/book/index.html" },
82027
82685
  { source: "/book/", destination: "/book/index.html" }
82028
82686
  ]
@@ -82043,6 +82701,7 @@ export default defineConfig({ plugins: [react()] });`;
82043
82701
  // Refresh the CMS admin app (contains the Bookings tab now)
82044
82702
  { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
82045
82703
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" },
82704
+ { file: "public/edit/index.html", data: editHtml(), encoding: "utf-8" },
82046
82705
  { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" },
82047
82706
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
82048
82707
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
@@ -82108,6 +82767,7 @@ export default defineConfig({ plugins: [react()] });`;
82108
82767
  // admin the CMS sites use — it already has a Bookings tab.
82109
82768
  { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
82110
82769
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" },
82770
+ { file: "public/edit/index.html", data: editHtml(), encoding: "utf-8" },
82111
82771
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
82112
82772
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
82113
82773
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
@@ -82120,7 +82780,9 @@ export default defineConfig({ plugins: [react()] });`;
82120
82780
  data: JSON.stringify({
82121
82781
  rewrites: [
82122
82782
  { source: "/admin", destination: "/admin/index.html" },
82123
- { source: "/admin/", destination: "/admin/index.html" }
82783
+ { source: "/admin/", destination: "/admin/index.html" },
82784
+ { source: "/edit", destination: "/edit/index.html" },
82785
+ { source: "/edit/", destination: "/edit/index.html" }
82124
82786
  ]
82125
82787
  }),
82126
82788
  encoding: "utf-8"
@@ -82290,12 +82952,14 @@ export default defineConfig({ plugins: [react()] });`,
82290
82952
  rewrites: [
82291
82953
  { source: "/admin", destination: "/admin/index.html" },
82292
82954
  { source: "/admin/", destination: "/admin/index.html" },
82955
+ { source: "/edit", destination: "/edit/index.html" },
82956
+ { source: "/edit/", destination: "/edit/index.html" },
82293
82957
  { source: "/book", destination: "/book/index.html" },
82294
82958
  { source: "/book/", destination: "/book/index.html" }
82295
82959
  ]
82296
82960
  }),
82297
82961
  encoding: "utf-8"
82298
- }, { 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/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_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" });
82962
+ }, { 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/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_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" }, { file: "public/edit/index.html", data: editHtml(), encoding: "utf-8" });
82299
82963
  const env = {
82300
82964
  BLOB_READ_WRITE_TOKEN: blobToken,
82301
82965
  ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
@@ -82318,6 +82982,132 @@ export default defineConfig({ plugins: [react()] });`,
82318
82982
  onProgress(`Live at ${url}`);
82319
82983
  return { url };
82320
82984
  }
82985
+ async function addEditorToSite2(args) {
82986
+ const { lead, anthropicKey, vercelToken, blobToken, reuseAdminPassword, onProgress } = args;
82987
+ onProgress("Loading site source\u2026");
82988
+ const snapshot = loadSnapshot(lead.id);
82989
+ const appIdx = snapshot ? snapshot.findIndex((f) => f.file === "src/App.jsx") : -1;
82990
+ if (!snapshot || appIdx < 0) {
82991
+ throw new Error("No local copy of this site's code. Regenerate the website once (that saves a copy), then add the editor.");
82992
+ }
82993
+ const photoPaths = snapshot.map((f) => f.file.match(/^public\/(photo(\d+)\.jpg)$/)).filter((m) => !!m).sort((a, b) => Number(a[2]) - Number(b[2])).map((m) => `/${m[1]}`);
82994
+ let seed = null;
82995
+ if (!/["'`]\/api\/content["'`]/.test(snapshot[appIdx].data)) {
82996
+ onProgress("Connecting the page's text and photos to the editor\u2026");
82997
+ const wired = await wireAppToCms({
82998
+ anthropicKey,
82999
+ appJsx: snapshot[appIdx].data,
83000
+ lead
83001
+ });
83002
+ snapshot[appIdx] = { ...snapshot[appIdx], data: wired.app };
83003
+ saveSnapshot(lead.id, snapshot);
83004
+ seed = {
83005
+ reviews: [],
83006
+ theme: { variant: "premium-dark", accent: "#22c55e" },
83007
+ ...wired.content,
83008
+ photos: photoPaths.map((url2) => ({ url: url2, alt: "" }))
83009
+ };
83010
+ }
83011
+ const siteUid = siteUidFor(lead.id);
83012
+ if (seed) {
83013
+ onProgress("Saving the page's current text\u2026");
83014
+ const { put } = await Promise.resolve().then(() => __importStar(require_dist5()));
83015
+ await put(`sites/${siteUid}/content.json`, JSON.stringify(seed), {
83016
+ access: "public",
83017
+ addRandomSuffix: false,
83018
+ allowOverwrite: true,
83019
+ contentType: "application/json",
83020
+ token: blobToken
83021
+ });
83022
+ }
83023
+ const password = reuseAdminPassword ?? (0, cms_runtime_1.generateFriendlyPassword)();
83024
+ const { url } = await redeployWithoutBanner({
83025
+ lead,
83026
+ vercelToken,
83027
+ blobToken,
83028
+ hasCms: true,
83029
+ adminPassword: password,
83030
+ siteUid,
83031
+ onProgress
83032
+ });
83033
+ return { url, admin: { url: `${url}/admin`, password } };
83034
+ }
83035
+ async function wireAppToCms(args) {
83036
+ const { anthropicKey, appJsx, lead } = args;
83037
+ const prompt = `You are editing an existing, approved React website (App.jsx below) so its owner can edit the copy through a CMS. The design is final: the page must look IDENTICAL after your change.
83038
+
83039
+ Change ONLY where the text comes from:
83040
+
83041
+ 1. Add near the top:
83042
+ const FALLBACK = { ...exactly the content JSON you output below... };
83043
+ 2. Inside App:
83044
+ const [content, setContent] = useState(FALLBACK);
83045
+ useEffect(() => {
83046
+ fetch("/api/content").then(r => r.ok ? r.json() : null).then(d => {
83047
+ if (d && d.business) setContent(prev => ({ ...prev, ...d }));
83048
+ }).catch(() => {});
83049
+ }, []);
83050
+ If the file has several components (pages, sections), pass content down as a prop \u2014 keep the fetch in App only.
83051
+ 3. Replace each hardcoded string that matches a field below with that field, e.g. <h1>{content.hero.headline}</h1>. The rendered text must stay character-for-character the same, so put the exact current wording into the JSON.
83052
+ 4. Photos: const photos = (content.photos && content.photos.length > 0) ? content.photos.map(p => p.url) : PHOTOS; and use photos wherever PHOTOS / photo paths were used. If the file has no PHOTOS constant, define const PHOTOS = [the photo paths it uses, in order].
83053
+ 5. Opening hours: render content.contact.hours if the page shows hours; if that array is empty the page must omit the hours block.
83054
+
83055
+ Content fields (use only what the page actually shows; leave others as "" or []):
83056
+ {
83057
+ "business": { "name": string, "tagline": string },
83058
+ "hero": { "headline": string, "subheadline": string, "ctaText": string },
83059
+ "about": string,
83060
+ "services": [{ "title": string, "description": string, "price": string }],
83061
+ "contact": { "address": string, "phone": string, "email": string, "hours": [string] }
83062
+ }
83063
+ Business name: ${JSON.stringify(lead.name)}. Phone: ${JSON.stringify(lead.phone ?? "")}. Address: ${JSON.stringify(lead.address ?? "")}.
83064
+
83065
+ Do NOT change: layout, styles, colours, fonts, class names, component structure, section order, animations, links, imports (other than adding useState/useEffect), or any text that has no field above \u2014 leave that hardcoded. Keep \`export default App;\` as the final line.
83066
+
83067
+ Reply in exactly this format and nothing else:
83068
+ <content>
83069
+ { the content JSON }
83070
+ </content>
83071
+ <app>
83072
+ the complete new App.jsx
83073
+ </app>
83074
+
83075
+ App.jsx:
83076
+ ${appJsx}`;
83077
+ let lastErr = "";
83078
+ for (let attempt = 0; attempt < 2; attempt++) {
83079
+ const res = await (0, llm_1.generateText)({
83080
+ apiKey: anthropicKey,
83081
+ prompt: attempt === 0 ? prompt : `${prompt}
83082
+
83083
+ Your previous reply was unusable (${lastErr}). Follow the reply format exactly.`,
83084
+ model: (0, models_1.getPreferredModel)(),
83085
+ maxTokens: 64e3,
83086
+ timeoutMs: 3e5
83087
+ });
83088
+ const text = res.text ?? "";
83089
+ const contentRaw = text.match(/<content>\s*([\s\S]*?)\s*<\/content>/)?.[1];
83090
+ const appRaw = text.match(/<app>\s*([\s\S]*?)\s*<\/app>/)?.[1];
83091
+ if (!contentRaw || !appRaw) {
83092
+ lastErr = res.stopReason === "max_tokens" ? "it was cut off" : "missing <content> or <app>";
83093
+ continue;
83094
+ }
83095
+ let content;
83096
+ try {
83097
+ content = JSON.parse(contentRaw.replace(/^```[a-z]*\s*/i, "").replace(/\s*```$/, ""));
83098
+ } catch {
83099
+ lastErr = "the content wasn't valid JSON";
83100
+ continue;
83101
+ }
83102
+ const app = appRaw.replace(/^```[a-z]*\s*/i, "").replace(/\s*```$/, "").trim();
83103
+ if (!app.includes("export default App") || !/\/api\/content/.test(app)) {
83104
+ lastErr = "the code didn't fetch /api/content or was incomplete";
83105
+ continue;
83106
+ }
83107
+ return { app, content };
83108
+ }
83109
+ throw new Error(`Couldn't connect this site to the editor (${lastErr}). Try again \u2014 nothing was changed.`);
83110
+ }
82321
83111
  function hasBookLink(code) {
82322
83112
  return /(href\s*=\s*["'`{]*\s*["'`]?\/book\/?["'`}]?)|(location(?:\.href)?\s*=\s*["'`]\/book["'`])/i.test(code);
82323
83113
  }
@@ -82572,6 +83362,7 @@ var require_places = __commonJS({
82572
83362
  async function searchOneType(args) {
82573
83363
  const all = [];
82574
83364
  let pageToken;
83365
+ let error;
82575
83366
  for (let page = 0; page < 3; page++) {
82576
83367
  const body = {
82577
83368
  textQuery: `${args.type} in ${args.city}`,
@@ -82598,11 +83389,13 @@ var require_places = __commonJS({
82598
83389
  pageToken = r.data.nextPageToken;
82599
83390
  if (!pageToken)
82600
83391
  break;
82601
- } catch {
83392
+ } catch (e) {
83393
+ const ax = e;
83394
+ error = ax.response?.data?.error?.message ?? ax.message ?? "Places request failed";
82602
83395
  break;
82603
83396
  }
82604
83397
  }
82605
- return { type: args.type, places: all };
83398
+ return { type: args.type, places: all, error };
82606
83399
  }
82607
83400
  function processRawPlaces(type, places, center, radiusMeters, includeWithWebsite) {
82608
83401
  const out = [];
@@ -82640,16 +83433,28 @@ var require_places = __commonJS({
82640
83433
  return out;
82641
83434
  }
82642
83435
  async function searchPlacesStream(args, onBatch) {
83436
+ const stats = { scanned: 0, withWebsite: 0, outsideArea: 0, errors: [] };
82643
83437
  await Promise.all(args.types.map(async (type) => {
82644
- const { places } = await searchOneType({
83438
+ const { places, error } = await searchOneType({
82645
83439
  apiKey: args.apiKey,
82646
83440
  city: args.city,
82647
83441
  type,
82648
83442
  center: args.center,
82649
83443
  radiusMeters: args.radiusMeters
82650
83444
  });
83445
+ if (error)
83446
+ stats.errors.push(error);
83447
+ stats.scanned += places.length;
83448
+ for (const p of places) {
83449
+ if (p.websiteUri)
83450
+ stats.withWebsite++;
83451
+ else if (args.center && args.radiusMeters && p.location && haversineMeters(args.center.lat, args.center.lng, p.location.latitude, p.location.longitude) > args.radiusMeters) {
83452
+ stats.outsideArea++;
83453
+ }
83454
+ }
82651
83455
  await onBatch(processRawPlaces(type, places, args.center, args.radiusMeters, args.includeWithWebsite));
82652
83456
  }));
83457
+ return stats;
82653
83458
  }
82654
83459
  async function searchPlaces(args) {
82655
83460
  const batches = await Promise.all(args.types.map((type) => searchOneType({
@@ -82737,15 +83542,24 @@ var require_places = __commonJS({
82737
83542
  if (!query.trim())
82738
83543
  return [];
82739
83544
  const r = await axios_1.default.get("https://nominatim.openstreetmap.org/search", {
82740
- params: { q: query, format: "json", limit, addressdetails: 1 },
83545
+ params: { q: query, format: "json", limit, addressdetails: 1, "accept-language": "en" },
82741
83546
  headers: { "User-Agent": "Sproutio/0.1 (desktop app)" }
82742
83547
  });
82743
83548
  return (r.data ?? []).map((d) => ({
82744
83549
  lat: parseFloat(d.lat),
82745
83550
  lng: parseFloat(d.lon),
82746
- label: d.display_name
83551
+ label: d.display_name,
83552
+ short: shortPlaceName(d.address, d.display_name)
82747
83553
  }));
82748
83554
  }
83555
+ function shortPlaceName(a, fallback) {
83556
+ if (!a)
83557
+ return fallback;
83558
+ const place = a.suburb && (a.city || a.town) ? `${a.suburb}, ${a.city || a.town}` : a.city || a.town || a.village || a.hamlet || a.suburb || a.municipality || a.county;
83559
+ const region = a.state_district || a.county || a.state;
83560
+ const parts = [place, region !== place ? region : null, a.country].filter(Boolean);
83561
+ return parts.length ? [...new Set(parts)].join(", ") : fallback;
83562
+ }
82749
83563
  async function geocodeReverse(lat, lng) {
82750
83564
  const r = await axios_1.default.get("https://nominatim.openstreetmap.org/reverse", {
82751
83565
  params: { lat, lon: lng, format: "json", addressdetails: 1, zoom: 16 },
@@ -83253,7 +84067,8 @@ var require_lead_search = __commonJS({
83253
84067
  const wantsSiteHavers = bookingMode || auditMode;
83254
84068
  let saved = 0;
83255
84069
  let found = 0;
83256
- await (0, places_1.searchPlacesStream)({
84070
+ let alreadyInPipeline = 0;
84071
+ const stats = await (0, places_1.searchPlacesStream)({
83257
84072
  apiKey: args.placesKey,
83258
84073
  city: args.city,
83259
84074
  types: args.types,
@@ -83270,8 +84085,10 @@ var require_lead_search = __commonJS({
83270
84085
  continue;
83271
84086
  seen.add(r.place_id);
83272
84087
  const existing = db2.all("SELECT id FROM leads WHERE place_id = ?", [r.place_id]);
83273
- if (existing.length > 0)
84088
+ if (existing.length > 0) {
84089
+ alreadyInPipeline++;
83274
84090
  continue;
84091
+ }
83275
84092
  let leadKind = "no_website";
83276
84093
  let bookingStatus = null;
83277
84094
  let bookingPlatform = null;
@@ -83338,7 +84155,15 @@ var require_lead_search = __commonJS({
83338
84155
  });
83339
84156
  }
83340
84157
  });
83341
- return { found, saved };
84158
+ return {
84159
+ found,
84160
+ saved,
84161
+ scanned: stats.scanned,
84162
+ withWebsite: stats.withWebsite,
84163
+ outsideArea: stats.outsideArea,
84164
+ alreadyInPipeline,
84165
+ errors: [...new Set(stats.errors)]
84166
+ };
83342
84167
  }
83343
84168
  async function searchAndSaveLeads(args) {
83344
84169
  const db2 = (0, db_1.getDb)();
@@ -95590,8 +96415,8 @@ var require_email = __commonJS({
95590
96415
  var nodemailer_1 = __importDefault(require_nodemailer());
95591
96416
  function smtpFromSettings(get) {
95592
96417
  const host = get("smtp_host");
95593
- const user = get("smtp_user");
95594
- const pass = get("smtp_pass");
96418
+ const user = get("smtp_user") || get("imap_user");
96419
+ const pass = get("smtp_pass") || get("imap_pass");
95595
96420
  if (!host || !user || !pass)
95596
96421
  return null;
95597
96422
  const port = Number(get("smtp_port") || "587");
@@ -95611,7 +96436,9 @@ var require_email = __commonJS({
95611
96436
  from: cfg.from,
95612
96437
  to: args.to,
95613
96438
  subject: args.subject,
95614
- text: args.text
96439
+ text: args.text,
96440
+ ...args.inReplyTo ? { inReplyTo: args.inReplyTo } : {},
96441
+ ...args.references ? { references: args.references } : {}
95615
96442
  });
95616
96443
  return { ok: true, messageId: info.messageId };
95617
96444
  } catch (e) {
@@ -95706,6 +96533,488 @@ Respond with ONLY compact JSON, no prose: {"email": string|null}`;
95706
96533
  }
95707
96534
  });
95708
96535
 
96536
+ // ../desktop/dist/main/lib/guardrails.js
96537
+ var require_guardrails = __commonJS({
96538
+ "../desktop/dist/main/lib/guardrails.js"(exports2) {
96539
+ "use strict";
96540
+ Object.defineProperty(exports2, "__esModule", { value: true });
96541
+ exports2.getAutopilotSettings = getAutopilotSettings;
96542
+ exports2.setAutopilotSettings = setAutopilotSettings;
96543
+ exports2.emailsSentToday = emailsSentToday;
96544
+ exports2.sendBlockedReason = sendBlockedReason;
96545
+ exports2.isSuppressed = isSuppressed;
96546
+ exports2.suppress = suppress;
96547
+ exports2.unsuppress = unsuppress;
96548
+ exports2.listSuppressed = listSuppressed;
96549
+ exports2.logActivity = logActivity;
96550
+ exports2.listActivity = listActivity;
96551
+ exports2.clearNeedsYou = clearNeedsYou;
96552
+ var db_1 = require_db();
96553
+ var DEFAULTS = {
96554
+ paused: false,
96555
+ replyMode: "draft",
96556
+ sendProposals: true,
96557
+ handoverMode: "ask",
96558
+ maxEmailsPerDay: 30,
96559
+ minConfidence: 0.8,
96560
+ sitePence: 19900,
96561
+ carePence: 0,
96562
+ callUrl: null
96563
+ };
96564
+ function read(key) {
96565
+ const row = (0, db_1.getDb)().get("SELECT value FROM settings WHERE key = ?", [key]);
96566
+ return row?.value ?? null;
96567
+ }
96568
+ function write(key, value) {
96569
+ (0, db_1.getDb)().run("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [key, value]);
96570
+ }
96571
+ var num = (v, d) => v != null && v !== "" && !Number.isNaN(Number(v)) ? Number(v) : d;
96572
+ function getAutopilotSettings() {
96573
+ const mode = read("auto_reply_mode");
96574
+ return {
96575
+ paused: read("autopilot_paused") === "1",
96576
+ replyMode: mode === "off" || mode === "auto" || mode === "draft" ? mode : DEFAULTS.replyMode,
96577
+ sendProposals: read("auto_send_proposals") !== "0",
96578
+ handoverMode: read("auto_handover_mode") === "auto" ? "auto" : "ask",
96579
+ maxEmailsPerDay: num(read("cap_emails_per_day"), DEFAULTS.maxEmailsPerDay),
96580
+ minConfidence: num(read("auto_min_confidence"), DEFAULTS.minConfidence),
96581
+ sitePence: num(read("price_site_pence"), DEFAULTS.sitePence),
96582
+ carePence: num(read("price_care_pence"), DEFAULTS.carePence),
96583
+ callUrl: read("call_booking_url") || null
96584
+ };
96585
+ }
96586
+ function setAutopilotSettings(patch) {
96587
+ if (patch.paused !== void 0)
96588
+ write("autopilot_paused", patch.paused ? "1" : "0");
96589
+ if (patch.replyMode)
96590
+ write("auto_reply_mode", patch.replyMode);
96591
+ if (patch.sendProposals !== void 0)
96592
+ write("auto_send_proposals", patch.sendProposals ? "1" : "0");
96593
+ if (patch.handoverMode)
96594
+ write("auto_handover_mode", patch.handoverMode);
96595
+ if (patch.maxEmailsPerDay !== void 0)
96596
+ write("cap_emails_per_day", String(Math.max(0, Math.floor(patch.maxEmailsPerDay))));
96597
+ if (patch.minConfidence !== void 0)
96598
+ write("auto_min_confidence", String(Math.min(1, Math.max(0.5, patch.minConfidence))));
96599
+ if (patch.sitePence !== void 0)
96600
+ write("price_site_pence", String(Math.max(0, Math.round(patch.sitePence))));
96601
+ if (patch.carePence !== void 0)
96602
+ write("price_care_pence", String(Math.max(0, Math.round(patch.carePence))));
96603
+ if (patch.callUrl !== void 0)
96604
+ write("call_booking_url", patch.callUrl ?? "");
96605
+ return getAutopilotSettings();
96606
+ }
96607
+ function emailsSentToday() {
96608
+ const row = (0, db_1.getDb)().get(`SELECT COUNT(*) AS n FROM messages
96609
+ WHERE direction = 'out' AND date(at) = date('now')`, []);
96610
+ return row?.n ?? 0;
96611
+ }
96612
+ function sendBlockedReason() {
96613
+ const s = getAutopilotSettings();
96614
+ if (s.paused)
96615
+ return "Autopilot is paused.";
96616
+ const sent = emailsSentToday();
96617
+ if (sent >= s.maxEmailsPerDay)
96618
+ return `Daily email limit reached (${sent}/${s.maxEmailsPerDay}).`;
96619
+ return null;
96620
+ }
96621
+ var norm = (e) => e.trim().toLowerCase();
96622
+ function isSuppressed(email) {
96623
+ if (!email)
96624
+ return false;
96625
+ return !!(0, db_1.getDb)().get("SELECT 1 FROM do_not_contact WHERE email = ?", [norm(email)]);
96626
+ }
96627
+ function suppress(email, reason, leadId) {
96628
+ (0, db_1.getDb)().run(`INSERT INTO do_not_contact (email, lead_id, reason) VALUES (?, ?, ?)
96629
+ ON CONFLICT(email) DO UPDATE SET reason = excluded.reason`, [norm(email), leadId ?? null, reason]);
96630
+ }
96631
+ function unsuppress(email) {
96632
+ (0, db_1.getDb)().run("DELETE FROM do_not_contact WHERE email = ?", [norm(email)]);
96633
+ }
96634
+ function listSuppressed() {
96635
+ return (0, db_1.getDb)().all("SELECT email, lead_id, reason, at FROM do_not_contact ORDER BY at DESC", []);
96636
+ }
96637
+ function logActivity(args) {
96638
+ (0, db_1.getDb)().run("INSERT INTO activity_log (actor, action, lead_id, detail, needs_you) VALUES (?, ?, ?, ?, ?)", [args.actor ?? "autopilot", args.action, args.leadId ?? null, args.detail ?? null, args.needsYou ? 1 : 0]);
96639
+ }
96640
+ function listActivity(limit = 100) {
96641
+ return (0, db_1.getDb)().all(`SELECT a.*, l.name AS lead_name FROM activity_log a
96642
+ LEFT JOIN leads l ON l.id = a.lead_id
96643
+ ORDER BY a.id DESC LIMIT ?`, [limit]);
96644
+ }
96645
+ function clearNeedsYou(id) {
96646
+ (0, db_1.getDb)().run("UPDATE activity_log SET needs_you = 0 WHERE id = ?", [id]);
96647
+ }
96648
+ }
96649
+ });
96650
+
96651
+ // ../desktop/dist/main/lib/inbox.js
96652
+ var require_inbox = __commonJS({
96653
+ "../desktop/dist/main/lib/inbox.js"(exports2) {
96654
+ "use strict";
96655
+ Object.defineProperty(exports2, "__esModule", { value: true });
96656
+ exports2.recordOutgoing = recordOutgoing;
96657
+ exports2.recordIncoming = recordIncoming;
96658
+ exports2.listThreads = listThreads;
96659
+ exports2.getThread = getThread;
96660
+ exports2.extractReplyText = extractReplyText;
96661
+ exports2.runReplyAgent = runReplyAgent;
96662
+ exports2.sendDraft = sendDraft;
96663
+ exports2.dismissMessage = dismissMessage;
96664
+ exports2.resetForAgent = resetForAgent;
96665
+ exports2.sendFollowUps = sendFollowUps;
96666
+ var db_1 = require_db();
96667
+ var email_1 = require_email();
96668
+ var llm_1 = require_llm();
96669
+ var followup_1 = require_followup();
96670
+ var agency_1 = require_agency();
96671
+ var guardrails_1 = require_guardrails();
96672
+ var AUTO_SENDABLE = ["interested", "price_question", "question"];
96673
+ function recordOutgoing(args) {
96674
+ (0, db_1.getDb)().run(`INSERT OR IGNORE INTO messages
96675
+ (lead_id, direction, from_addr, to_addr, subject, body, message_id, in_reply_to, handled, sent_by)
96676
+ VALUES (?, 'out', ?, ?, ?, ?, ?, ?, 'done', ?)`, [args.leadId, args.from ?? null, args.to, args.subject, args.body, args.messageId ?? null, args.inReplyTo ?? null, args.sentBy]);
96677
+ if (args.messageId) {
96678
+ (0, db_1.getDb)().run("UPDATE leads SET last_email_message_id = ? WHERE id = ?", [args.messageId, args.leadId]);
96679
+ }
96680
+ }
96681
+ function recordIncoming(args) {
96682
+ const db2 = (0, db_1.getDb)();
96683
+ if (args.messageId && db2.get("SELECT 1 FROM messages WHERE message_id = ?", [args.messageId]))
96684
+ return false;
96685
+ db2.run(`INSERT INTO messages (lead_id, direction, from_addr, subject, body, message_id, in_reply_to, at)
96686
+ VALUES (?, 'in', ?, ?, ?, ?, ?, ?)`, [
96687
+ args.leadId,
96688
+ args.from,
96689
+ args.subject,
96690
+ args.body,
96691
+ args.messageId,
96692
+ args.inReplyTo,
96693
+ (args.at ?? /* @__PURE__ */ new Date()).toISOString()
96694
+ ]);
96695
+ return true;
96696
+ }
96697
+ function listThreads() {
96698
+ const rows = (0, db_1.getDb)().all(`SELECT l.id AS lead_id, l.name, l.email, l.status, l.site_url,
96699
+ m.at AS last_at, m.direction AS last_direction, substr(m.body, 1, 160) AS last_preview,
96700
+ (SELECT COUNT(*) FROM messages p WHERE p.lead_id = l.id AND p.direction = 'in'
96701
+ AND p.handled IN ('new','drafted','needs_you')) AS pending,
96702
+ (SELECT intent FROM messages q WHERE q.lead_id = l.id AND q.direction = 'in'
96703
+ AND q.intent IS NOT NULL ORDER BY q.at DESC LIMIT 1) AS latest_intent
96704
+ FROM leads l
96705
+ JOIN messages m ON m.id = (SELECT id FROM messages WHERE lead_id = l.id ORDER BY at DESC, id DESC LIMIT 1)
96706
+ ORDER BY pending > 0 DESC, m.at DESC`, []);
96707
+ return rows.map((r) => ({ ...r, suppressed: (0, guardrails_1.isSuppressed)(r.email) }));
96708
+ }
96709
+ function getThread(leadId) {
96710
+ return (0, db_1.getDb)().all("SELECT * FROM messages WHERE lead_id = ? ORDER BY at ASC, id ASC", [leadId]);
96711
+ }
96712
+ function splitHead(raw) {
96713
+ const m = raw.match(/\r?\n\r?\n/);
96714
+ if (!m || m.index === void 0)
96715
+ return [raw, ""];
96716
+ return [raw.slice(0, m.index), raw.slice(m.index + m[0].length)];
96717
+ }
96718
+ function header(head, name) {
96719
+ const unfolded = head.replace(/\r?\n[ \t]+/g, " ");
96720
+ const line = unfolded.split(/\r?\n/).find((l) => l.toLowerCase().startsWith(name.toLowerCase() + ":"));
96721
+ return line ? line.slice(line.indexOf(":") + 1).trim() : "";
96722
+ }
96723
+ function decodeBody(body, encoding) {
96724
+ const enc = encoding.toLowerCase();
96725
+ if (enc === "base64") {
96726
+ try {
96727
+ return Buffer.from(body.replace(/\s+/g, ""), "base64").toString("utf-8");
96728
+ } catch {
96729
+ return body;
96730
+ }
96731
+ }
96732
+ if (enc === "quoted-printable") {
96733
+ const bytes = body.replace(/=\r?\n/g, "").replace(/=([0-9A-F]{2})/gi, (_, h) => String.fromCharCode(parseInt(h, 16)));
96734
+ return Buffer.from(bytes, "latin1").toString("utf-8");
96735
+ }
96736
+ return body;
96737
+ }
96738
+ function htmlToText(html) {
96739
+ return html.replace(/<(style|script)[\s\S]*?<\/\1>/gi, "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|div|li|tr|h\d)>/gi, "\n").replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&#39;|&apos;/g, "'").replace(/&quot;/g, '"');
96740
+ }
96741
+ function partText(raw, depth = 0) {
96742
+ const [head, body] = splitHead(raw);
96743
+ const type = header(head, "content-type").toLowerCase();
96744
+ const enc = header(head, "content-transfer-encoding");
96745
+ if (type.startsWith("multipart/") && depth < 5) {
96746
+ const b = header(head, "content-type").match(/boundary="?([^";]+)"?/i)?.[1];
96747
+ if (!b)
96748
+ return { plain: body };
96749
+ const out = {};
96750
+ for (const part of body.split(`--${b}`).slice(1)) {
96751
+ if (part.startsWith("--"))
96752
+ break;
96753
+ const r = partText(part.replace(/^\r?\n/, ""), depth + 1);
96754
+ out.plain ??= r.plain;
96755
+ out.html ??= r.html;
96756
+ }
96757
+ return out;
96758
+ }
96759
+ if (type.includes("text/html"))
96760
+ return { html: decodeBody(body, enc) };
96761
+ if (!type || type.includes("text/plain"))
96762
+ return { plain: decodeBody(body, enc) };
96763
+ return {};
96764
+ }
96765
+ function extractReplyText(raw) {
96766
+ const parts = partText(raw);
96767
+ const text = parts.plain ?? (parts.html ? htmlToText(parts.html) : "");
96768
+ const lines = [];
96769
+ for (const line of text.split(/\r?\n/)) {
96770
+ const t = line.trim();
96771
+ if (t === "--" || t === "-- ")
96772
+ break;
96773
+ if (/^On .+wrote:$/i.test(t) || /^-{2,} ?Original Message ?-{2,}$/i.test(t) || /^From: .+/.test(t) && lines.length > 0)
96774
+ break;
96775
+ if (t.startsWith(">"))
96776
+ continue;
96777
+ lines.push(line.trimEnd());
96778
+ }
96779
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim().slice(0, 4e3);
96780
+ }
96781
+ function parseJson(text) {
96782
+ const m = text.match(/\{[\s\S]*\}/);
96783
+ if (!m)
96784
+ return null;
96785
+ try {
96786
+ const j = JSON.parse(m[0]);
96787
+ const intents = ["interested", "price_question", "question", "change_request", "not_interested", "unsubscribe", "auto_reply", "other"];
96788
+ return {
96789
+ intent: intents.includes(j.intent) ? j.intent : "other",
96790
+ confidence: Math.max(0, Math.min(1, Number(j.confidence) || 0)),
96791
+ summary: String(j.summary ?? "").slice(0, 300),
96792
+ reply_subject: j.reply_subject ? String(j.reply_subject) : null,
96793
+ reply_body: j.reply_body ? String(j.reply_body) : null
96794
+ };
96795
+ } catch {
96796
+ return null;
96797
+ }
96798
+ }
96799
+ async function analyse(msg, deps) {
96800
+ const db2 = (0, db_1.getDb)();
96801
+ const lead = db2.get("SELECT name, type, city, status, site_url, booking_url, handed_over_at FROM leads WHERE id = ?", [msg.lead_id]);
96802
+ if (!lead)
96803
+ return null;
96804
+ const s = (0, guardrails_1.getAutopilotSettings)();
96805
+ const agency2 = (0, agency_1.getAgencyProfile)();
96806
+ const history = getThread(msg.lead_id).filter((m) => m.id !== msg.id).slice(-6).map((m) => `${m.direction === "out" ? "US" : "THEM"} (${m.at.slice(0, 10)}): ${(m.body ?? "").slice(0, 1200)}`).join("\n\n");
96807
+ const isClient = !!lead.handed_over_at || lead.status === "won";
96808
+ const prompt = `You handle email replies for ${agency2.name ?? "a small web design studio"}, which builds websites for local businesses.
96809
+
96810
+ Business you're talking to: ${lead.name}${lead.type ? ` (${lead.type})` : ""}${lead.city ? `, ${lead.city}` : ""}.
96811
+ ${isClient ? "They are already a paying client." : `They're a prospect. We built them a free demo site${lead.site_url ? `: ${lead.site_url}` : ""}.`}
96812
+
96813
+ Pricing you may quote (never discount or promise anything else):
96814
+ - Website made live on their own domain: ${(0, agency_1.formatMoney)(s.sitePence, agency2.currency)} one-off.
96815
+ ${s.carePence > 0 ? `- Optional care plan (hosting, updates, small edits): ${(0, agency_1.formatMoney)(s.carePence, agency2.currency)} a month.` : "- Hosting is included for the first year."}
96816
+ ${s.callUrl ? `Call booking link: ${s.callUrl}` : "No call booking link \u2014 offer to reply by email or ask for a good time to call."}
96817
+
96818
+ Earlier emails:
96819
+ ${history || "(none stored)"}
96820
+
96821
+ Their new email:
96822
+ ${(msg.body ?? "").slice(0, 3e3)}
96823
+
96824
+ Classify the new email and, unless it's an unsubscribe, an auto-reply or a clear no, write the reply we should send.
96825
+
96826
+ Intents: interested (wants to go ahead or keen), price_question, question (anything else they ask), change_request (wants changes to the site), not_interested, unsubscribe (asks not to be contacted), auto_reply (out-of-office or automated), other.
96827
+
96828
+ Reply rules:
96829
+ - Plain, friendly, professional British English. Short: 60\u2013140 words. No hype, no pressure.
96830
+ - Answer exactly what they asked. If you don't know something (dates, technical specifics not given above), say we'll confirm rather than inventing it.
96831
+ - If they're interested or asking the price and want to proceed, include the literal placeholder {payment_link} on its own line as the way to go ahead.
96832
+ - Use {call_link} only if a call booking link exists above.
96833
+ - Sign off as ${agency2.name ?? "the team"}.
96834
+ - Never claim results, guarantees or testimonials.
96835
+
96836
+ Respond with JSON only:
96837
+ {"intent": "...", "confidence": 0.0-1.0, "summary": "one sentence on what they want", "reply_subject": "Re: ..." or null, "reply_body": "..." or null}`;
96838
+ const res = await (0, llm_1.generateText)({ apiKey: deps.apiKey, prompt, maxTokens: 1500, timeoutMs: 12e4 });
96839
+ return parseJson(res.text);
96840
+ }
96841
+ function fill(body, links) {
96842
+ let out = body;
96843
+ out = links.payment ? out.replace(/\{payment_link\}/g, links.payment) : out.split("\n").filter((l) => !l.includes("{payment_link}")).join("\n");
96844
+ out = links.call ? out.replace(/\{call_link\}/g, links.call) : out.split("\n").filter((l) => !l.includes("{call_link}")).join("\n");
96845
+ return out.trim();
96846
+ }
96847
+ async function runReplyAgent(deps) {
96848
+ const settings = (0, guardrails_1.getAutopilotSettings)();
96849
+ if (settings.replyMode === "off")
96850
+ return { processed: 0, sent: 0 };
96851
+ const db2 = (0, db_1.getDb)();
96852
+ const pending = db2.all("SELECT * FROM messages WHERE direction = 'in' AND handled = 'new' ORDER BY at ASC LIMIT 10", []);
96853
+ let processed = 0;
96854
+ let sent = 0;
96855
+ for (const msg of pending) {
96856
+ let a = null;
96857
+ try {
96858
+ a = await analyse(msg, deps);
96859
+ } catch (e) {
96860
+ (0, guardrails_1.logActivity)({ action: "Couldn't read reply", leadId: msg.lead_id, detail: e.message, needsYou: true });
96861
+ }
96862
+ if (!a) {
96863
+ db2.run("UPDATE messages SET handled = 'needs_you' WHERE id = ?", [msg.id]);
96864
+ continue;
96865
+ }
96866
+ processed++;
96867
+ db2.run("UPDATE messages SET intent = ?, intent_summary = ?, confidence = ? WHERE id = ?", [a.intent, a.summary, a.confidence, msg.id]);
96868
+ if (a.intent === "unsubscribe" || a.intent === "not_interested") {
96869
+ if (msg.from_addr)
96870
+ (0, guardrails_1.suppress)(msg.from_addr, a.intent === "unsubscribe" ? "unsubscribed" : "not interested", msg.lead_id);
96871
+ (0, followup_1.logContact)({ leadId: msg.lead_id, direction: "in", channel: "email", outcome: "not_interested", note: a.summary });
96872
+ db2.run("UPDATE messages SET handled = 'done' WHERE id = ?", [msg.id]);
96873
+ (0, guardrails_1.logActivity)({ action: a.intent === "unsubscribe" ? "Unsubscribed \u2014 won't be contacted again" : "Not interested \u2014 closed", leadId: msg.lead_id, detail: a.summary });
96874
+ continue;
96875
+ }
96876
+ if (a.intent === "auto_reply") {
96877
+ db2.run("UPDATE messages SET handled = 'ignored' WHERE id = ?", [msg.id]);
96878
+ continue;
96879
+ }
96880
+ if (a.intent === "interested") {
96881
+ (0, followup_1.logContact)({ leadId: msg.lead_id, direction: "in", channel: "email", outcome: "interested", note: a.summary });
96882
+ }
96883
+ let body = a.reply_body ?? "";
96884
+ let payment = null;
96885
+ if (body.includes("{payment_link}") && settings.sendProposals) {
96886
+ try {
96887
+ payment = await deps.createPaymentLink(msg.lead_id);
96888
+ } catch (e) {
96889
+ (0, guardrails_1.logActivity)({ action: "Couldn't create payment link", leadId: msg.lead_id, detail: e.message, needsYou: true });
96890
+ }
96891
+ }
96892
+ body = fill(body, { payment, call: settings.callUrl });
96893
+ const subject = a.reply_subject || `Re: ${msg.subject ?? ""}`.trim();
96894
+ db2.run("UPDATE messages SET draft_subject = ?, draft_body = ?, handled = 'drafted' WHERE id = ?", [subject, body, msg.id]);
96895
+ const canAuto = settings.replyMode === "auto" && AUTO_SENDABLE.includes(a.intent) && a.confidence >= settings.minConfidence && !!body && !(0, guardrails_1.isSuppressed)(msg.from_addr) && !(0, guardrails_1.sendBlockedReason)();
96896
+ if (canAuto) {
96897
+ const r = await sendDraft(msg.id, { subject, body }, deps.smtp, "autopilot");
96898
+ if (r.ok) {
96899
+ sent++;
96900
+ (0, guardrails_1.logActivity)({ action: `Replied automatically (${a.intent.replace("_", " ")})`, leadId: msg.lead_id, detail: a.summary });
96901
+ continue;
96902
+ }
96903
+ (0, guardrails_1.logActivity)({ action: "Auto-reply failed to send", leadId: msg.lead_id, detail: r.error, needsYou: true });
96904
+ }
96905
+ const why = settings.replyMode === "draft" ? "Draft ready for you to check" : !AUTO_SENDABLE.includes(a.intent) ? `Needs you (${a.intent.replace("_", " ")})` : a.confidence < settings.minConfidence ? "Needs you (agent wasn't sure)" : (0, guardrails_1.sendBlockedReason)() ?? "Needs you";
96906
+ (0, guardrails_1.logActivity)({ action: why, leadId: msg.lead_id, detail: a.summary, needsYou: true });
96907
+ }
96908
+ if (processed)
96909
+ deps.emit?.("inbox:changed", { processed, sent });
96910
+ return { processed, sent };
96911
+ }
96912
+ async function sendDraft(messageId, content, smtp, sentBy) {
96913
+ if (!smtp)
96914
+ return { ok: false, error: "Set up email sending in Settings first." };
96915
+ const db2 = (0, db_1.getDb)();
96916
+ const msg = db2.get("SELECT * FROM messages WHERE id = ?", [messageId]);
96917
+ if (!msg)
96918
+ return { ok: false, error: "Message not found." };
96919
+ if (!msg.from_addr)
96920
+ return { ok: false, error: "No address to reply to." };
96921
+ if ((0, guardrails_1.isSuppressed)(msg.from_addr))
96922
+ return { ok: false, error: "This address is on the do-not-contact list." };
96923
+ if (sentBy === "autopilot") {
96924
+ const blocked = (0, guardrails_1.sendBlockedReason)();
96925
+ if (blocked)
96926
+ return { ok: false, error: blocked };
96927
+ }
96928
+ const res = await (0, email_1.sendEmail)(smtp, {
96929
+ to: msg.from_addr,
96930
+ subject: content.subject,
96931
+ text: content.body,
96932
+ inReplyTo: msg.message_id,
96933
+ references: msg.message_id
96934
+ });
96935
+ if (!res.ok)
96936
+ return { ok: false, error: res.error };
96937
+ recordOutgoing({
96938
+ leadId: msg.lead_id,
96939
+ to: msg.from_addr,
96940
+ from: smtp.from,
96941
+ subject: content.subject,
96942
+ body: content.body,
96943
+ messageId: res.messageId,
96944
+ inReplyTo: msg.message_id,
96945
+ sentBy
96946
+ });
96947
+ db2.run("UPDATE messages SET handled = 'replied', draft_subject = ?, draft_body = ? WHERE id = ?", [content.subject, content.body, messageId]);
96948
+ (0, followup_1.logContact)({ leadId: msg.lead_id, direction: "out", channel: "email", note: content.subject });
96949
+ return { ok: true };
96950
+ }
96951
+ function dismissMessage(messageId) {
96952
+ (0, db_1.getDb)().run("UPDATE messages SET handled = 'done' WHERE id = ?", [messageId]);
96953
+ }
96954
+ function resetForAgent(messageId) {
96955
+ (0, db_1.getDb)().run("UPDATE messages SET handled = 'new', draft_subject = NULL, draft_body = NULL WHERE id = ? AND direction = 'in'", [messageId]);
96956
+ }
96957
+ async function sendFollowUps(args) {
96958
+ if (!args.smtp)
96959
+ return { sent: 0, skipped: 0, error: "Email sending isn't set up." };
96960
+ const db2 = (0, db_1.getDb)();
96961
+ const due = db2.all(`SELECT id, name, email, site_url, booking_url, last_email_message_id
96962
+ FROM leads
96963
+ WHERE last_contacted_at IS NOT NULL
96964
+ AND last_reply_at IS NULL AND replied_at IS NULL
96965
+ AND status NOT IN ('dead','won')
96966
+ AND COALESCE(followed_up_count, 0) <= 1
96967
+ AND email IS NOT NULL AND email != ''
96968
+ AND julianday('now') - julianday(last_contacted_at) >= ?
96969
+ ${args.onlyLeadIds ? `AND id IN (${args.onlyLeadIds.map(Number).join(",") || "NULL"})` : ""}
96970
+ ORDER BY last_contacted_at ASC
96971
+ LIMIT ?`, [args.afterDays, args.limit]);
96972
+ const agency2 = (0, agency_1.getAgencyProfile)().name ?? "";
96973
+ let sent = 0;
96974
+ let skipped = 0;
96975
+ for (const l of due) {
96976
+ if ((0, guardrails_1.isSuppressed)(l.email)) {
96977
+ skipped++;
96978
+ continue;
96979
+ }
96980
+ const blocked = (0, guardrails_1.sendBlockedReason)();
96981
+ if (blocked)
96982
+ return { sent, skipped: skipped + (due.length - sent - skipped), error: blocked };
96983
+ const link = l.site_url || l.booking_url || "";
96984
+ const subject = `Following up \u2014 ${l.name}`;
96985
+ const body = [
96986
+ "Hello,",
96987
+ "",
96988
+ `Just following up on the free demo website I put together for ${l.name}${link ? `:
96989
+ ${link}` : "."}`,
96990
+ "",
96991
+ `Happy to answer any questions or make changes. If it's not for you, just reply "no thanks" and I won't get in touch again.`,
96992
+ "",
96993
+ "Best regards",
96994
+ agency2
96995
+ ].join("\n");
96996
+ const res = await (0, email_1.sendEmail)(args.smtp, {
96997
+ to: l.email,
96998
+ subject,
96999
+ text: body,
97000
+ inReplyTo: l.last_email_message_id,
97001
+ references: l.last_email_message_id
97002
+ });
97003
+ if (!res.ok) {
97004
+ (0, guardrails_1.logActivity)({ action: "Follow-up failed to send", leadId: l.id, detail: res.error });
97005
+ skipped++;
97006
+ continue;
97007
+ }
97008
+ recordOutgoing({ leadId: l.id, to: l.email, from: args.smtp.from, subject, body, messageId: res.messageId ?? null, inReplyTo: l.last_email_message_id, sentBy: "autopilot" });
97009
+ (0, followup_1.logContact)({ leadId: l.id, direction: "out", channel: "email", note: "follow-up" });
97010
+ (0, guardrails_1.logActivity)({ action: "Sent a follow-up", leadId: l.id });
97011
+ sent++;
97012
+ }
97013
+ return { sent, skipped };
97014
+ }
97015
+ }
97016
+ });
97017
+
95709
97018
  // ../desktop/dist/main/lib/chat-tools.js
95710
97019
  var require_chat_tools = __commonJS({
95711
97020
  "../desktop/dist/main/lib/chat-tools.js"(exports2) {
@@ -95727,6 +97036,8 @@ var require_chat_tools = __commonJS({
95727
97036
  var site_stats_1 = require_site_stats();
95728
97037
  var email_1 = require_email();
95729
97038
  var email_harvest_1 = require_email_harvest();
97039
+ var guardrails_1 = require_guardrails();
97040
+ var inbox_1 = require_inbox();
95730
97041
  exports2.TOOLS = [
95731
97042
  {
95732
97043
  name: "find_leads",
@@ -95806,7 +97117,7 @@ var require_chat_tools = __commonJS({
95806
97117
  },
95807
97118
  {
95808
97119
  name: "whatsapp_message",
95809
- description: "Generate a pre-filled WhatsApp message link for a lead. Returns a URL the user can open. Doesn't actually send.",
97120
+ description: "Generate a pre-filled WhatsApp message link for a lead. Only use this when the user explicitly asks for WhatsApp \u2014 outreach is email by default. Returns a URL the user can open. Doesn't actually send.",
95810
97121
  input_schema: {
95811
97122
  type: "object",
95812
97123
  properties: {
@@ -95833,7 +97144,7 @@ var require_chat_tools = __commonJS({
95833
97144
  channel: {
95834
97145
  type: "string",
95835
97146
  enum: ["email", "whatsapp", "sms"],
95836
- description: "Preferred channel. 'email' (default) sends via the user's SMTP \u2014 the only channel that works unattended on every platform \u2014 falling back to a WhatsApp link for leads with no email address. 'whatsapp' opens WhatsApp and 'sms' opens the device's Messages app (Messages on macOS, Phone Link on Windows); both fall back to email when a lead has no phone. Omit to use the saved preference."
97147
+ description: "Preferred channel. 'email' (default, and the professional choice) sends via the user's SMTP \u2014 the only channel that works unattended on every platform. Leads with no email address are reported as unreachable, never switched to WhatsApp. 'whatsapp' opens WhatsApp and 'sms' opens the device's Messages app (Messages on macOS, Phone Link on Windows); both fall back to email when a lead has no phone. Omit to use the saved preference."
95837
97148
  }
95838
97149
  }
95839
97150
  },
@@ -96094,7 +97405,7 @@ var require_chat_tools = __commonJS({
96094
97405
  },
96095
97406
  {
96096
97407
  name: "provision_cms",
96097
- description: "Set up a CMS for a paying customer (lead in 'won' status). Creates Supabase records, redeploys with the dynamic CMS template, returns a one-time claim link.",
97408
+ description: "Give a built site its editor, keeping the design exactly as it is: the owner taps text and photos on their own page at <site>/edit (works on a phone) and has /admin for services, hours and photos. Returns the edit link and password to hand to the customer.",
96098
97409
  input_schema: {
96099
97410
  type: "object",
96100
97411
  properties: { id: { type: "number" } },
@@ -96202,11 +97513,19 @@ Want it live on your own domain?`;
96202
97513
  }
96203
97514
  function outreachTemplate(db2) {
96204
97515
  const row = db2.get("SELECT value FROM settings WHERE key = ?", ["outreach_template"]);
96205
- return row?.value ?? `Hi, we built {name} a free demo website: {site_url}
97516
+ return row?.value ?? `Hello,
97517
+
97518
+ I came across {name} on Google and noticed you don't have a website yet, so I put together a free demo to show what one could look like:
97519
+
97520
+ {site_url}
97521
+
97522
+ It's built from your real reviews and photos. If you like it, I can put it live on your own domain with your details for a one-off fee of \xA3199 \u2014 no monthly charges.
96206
97523
 
96207
- It's not fully live yet. We can make it live for \xA3199 \u2014 your own domain, photos & content. One-time fee.
97524
+ Would you be open to a quick chat this week?
96208
97525
 
96209
- Interested?`;
97526
+ Best regards
97527
+
97528
+ If you'd rather not hear from me again, just reply and let me know.`;
96210
97529
  }
96211
97530
  function normaliseChannel(value) {
96212
97531
  switch (String(value ?? "").toLowerCase()) {
@@ -96352,11 +97671,19 @@ Interested?`;
96352
97671
  const phone = (lead.phone ?? "").replace(/[^\d+]/g, "");
96353
97672
  const e164 = phone.startsWith("+") ? phone : phone.startsWith("0") ? `+44${phone.slice(1)}` : phone;
96354
97673
  const tplRow = db2.get("SELECT value FROM settings WHERE key = ?", ["outreach_template"]);
96355
- const template = tplRow?.value ?? `Hi, we built {name} a free demo website: {site_url}
97674
+ const template = tplRow?.value ?? `Hello,
97675
+
97676
+ I came across {name} on Google and noticed you don't have a website yet, so I put together a free demo to show what one could look like:
97677
+
97678
+ {site_url}
97679
+
97680
+ It's built from your real reviews and photos. If you like it, I can put it live on your own domain with your details for a one-off fee of \xA3199 \u2014 no monthly charges.
97681
+
97682
+ Would you be open to a quick chat this week?
96356
97683
 
96357
- It's not fully live yet. We can make it live for \xA3199 \u2014 your own domain, photos & content. One-time fee.
97684
+ Best regards
96358
97685
 
96359
- Interested?`;
97686
+ If you'd rather not hear from me again, just reply and let me know.`;
96360
97687
  const body = input.message ?? template.replace(/\{name\}/g, lead.name).replace(/\{site_url\}/g, lead.site_url ?? "");
96361
97688
  const url = `https://wa.me/${e164.replace(/^\+/, "")}?text=${encodeURIComponent(body)}`;
96362
97689
  return { url, message: body, phone: e164 };
@@ -96383,7 +97710,7 @@ Interested?`;
96383
97710
  const template = outreachTemplate(db2);
96384
97711
  const bookingTemplate = bookingOutreachTemplate(db2);
96385
97712
  const preferEmail = channel === "email";
96386
- const linkChannel = channel === "email" ? "whatsapp" : channel;
97713
+ const linkChannel = channel === "email" ? null : channel;
96387
97714
  const smtp = (0, email_1.smtpFromSettings)((k) => getDecryptedSetting(k));
96388
97715
  const emailSubject = getDecryptedSetting("outreach_email_subject") || "A free demo website for {name}";
96389
97716
  const prepared = [];
@@ -96409,9 +97736,17 @@ Interested?`;
96409
97736
  harvested++;
96410
97737
  }
96411
97738
  }
97739
+ if (r.email && (0, guardrails_1.isSuppressed)(r.email)) {
97740
+ prepared.push({ id: r.id, name: r.name, via: "none", email: r.email, message, emailError: "On the do-not-contact list" });
97741
+ continue;
97742
+ }
97743
+ if (preferEmail && (0, guardrails_1.emailsSentToday)() >= (0, guardrails_1.getAutopilotSettings)().maxEmailsPerDay) {
97744
+ prepared.push({ id: r.id, name: r.name, via: "none", email: r.email ?? void 0, message, emailError: "Daily email limit reached \u2014 try again tomorrow" });
97745
+ continue;
97746
+ }
96412
97747
  const leadEmail = smtp && r.email ? r.email : null;
96413
97748
  const useEmail = leadEmail !== null && (preferEmail || !e164);
96414
- if (!useEmail && e164) {
97749
+ if (!useEmail && e164 && linkChannel) {
96415
97750
  prepared.push({
96416
97751
  id: r.id,
96417
97752
  name: r.name,
@@ -96423,11 +97758,16 @@ Interested?`;
96423
97758
  } else if (useEmail && smtp && leadEmail) {
96424
97759
  const subject = emailSubject.replace(/\{name\}/g, r.name);
96425
97760
  const res = await (0, email_1.sendEmail)(smtp, { to: leadEmail, subject, text: message });
96426
- if (res.ok && res.messageId) {
96427
- db2.run("UPDATE leads SET last_email_message_id = ? WHERE id = ?", [
96428
- res.messageId,
96429
- r.id
96430
- ]);
97761
+ if (res.ok) {
97762
+ (0, inbox_1.recordOutgoing)({
97763
+ leadId: r.id,
97764
+ to: leadEmail,
97765
+ from: smtp.from,
97766
+ subject,
97767
+ body: message,
97768
+ messageId: res.messageId ?? null,
97769
+ sentBy: "outreach"
97770
+ });
96431
97771
  }
96432
97772
  prepared.push({
96433
97773
  id: r.id,
@@ -96457,7 +97797,7 @@ Interested?`;
96457
97797
  (0, followup_1.logContact)({
96458
97798
  leadId: r.id,
96459
97799
  direction: "out",
96460
- channel: last.via === "email" ? "email" : linkChannel
97800
+ channel: last.via === "email" ? "email" : linkChannel ?? "sms"
96461
97801
  });
96462
97802
  }
96463
97803
  }
@@ -96861,8 +98201,15 @@ Interested?`;
96861
98201
  }
96862
98202
  case "provision_cms": {
96863
98203
  const id = Number(input.id);
96864
- const result = await ctx.invokeIpc("cms:provision", id);
96865
- return result;
98204
+ const result = await ctx.invokeIpc("sites:addEditor", id);
98205
+ return {
98206
+ ok: true,
98207
+ id,
98208
+ publicUrl: result.url,
98209
+ editUrl: `${result.url}/edit`,
98210
+ adminUrl: result.admin?.url,
98211
+ adminPassword: result.admin?.password
98212
+ };
96866
98213
  }
96867
98214
  case "delete_lead": {
96868
98215
  const id = Number(input.id);
@@ -96890,7 +98237,7 @@ var require_chat = __commonJS({
96890
98237
  var sdk_1 = __importDefault(require_sdk());
96891
98238
  var chat_tools_1 = require_chat_tools();
96892
98239
  var models_1 = require_models();
96893
- var SYSTEM_PROMPT = `You are Sproutkit's built-in assistant \u2014 STRICTLY scoped to helping the user run their lead-generation, website-building, and outreach work inside the Sproutkit app.
98240
+ var SYSTEM_PROMPT = `You are Sprout, Sproutkit's built-in assistant (the operator's friendly sidekick) \u2014 STRICTLY scoped to helping the user run their lead-generation, website-building, and outreach work inside the Sproutkit app.
96894
98241
 
96895
98242
  You can do the user's real work through tools \u2014 you are not a help desk pointing at buttons. Many users prefer chatting to using the UI, so if something can be done with a tool, DO IT.
96896
98243
 
@@ -96937,7 +98284,7 @@ Confirmation rules:
96937
98284
  You can help with:
96938
98285
  - Finding leads (running the search itself, plus strategy, categories, filtering)
96939
98286
  - Building demo websites (design ideas, copy suggestions, fixing issues)
96940
- - Outreach (drafting WhatsApp / SMS / email messages, follow-ups, kanban moves)
98287
+ - Outreach (email by default \u2014 draft and send professional emails; only use WhatsApp/SMS when the user asks; follow-ups, kanban moves)
96941
98288
  - The CMS, claim flow, customer onboarding
96942
98289
  - Sproutkit features and how to use them
96943
98290
  - Pricing, business advice for selling websites to local small businesses
@@ -96946,7 +98293,7 @@ STAYING ON THE RAILS \u2014 you are capable, but you only act through the tools
96946
98293
  - You can ONLY do what a tool does. If a request maps to a tool (even loosely, via an instructions field), do it. If NO tool fits \u2014 e.g. "run their Google Ads", "post to their Instagram", "phone the owner", "take payment" \u2014 say so plainly in one line and name the closest thing you CAN do. Example: "I can't run ads, but I can build them a site and draft the outreach \u2014 want that?"
96947
98294
  - NEVER claim to have done something you have no tool for. NEVER invent a result, URL, price, or status. If you didn't get it from a tool result, don't state it as fact.
96948
98295
  - Don't drift into general marketing consultancy, coding help, or business strategy essays. A one-line pointer is fine; a lecture is not.
96949
- - Refuse anything genuinely off-topic (general coding, creative writing, personal advice, news, politics) briefly: "I'm Sproutkit's assistant \u2014 I help with finding leads, building sites, booking, and outreach. What would you like to do?" and stop.
98296
+ - Refuse anything genuinely off-topic (general coding, creative writing, personal advice, news, politics) briefly: "I'm Sprout, Sproutkit's assistant \u2014 I help with finding leads, building sites, booking, and outreach. What would you like to do?" and stop.
96950
98297
 
96951
98298
  Be concise, action-oriented, and use plain English. Never apologise excessively. Never lecture.
96952
98299
 
@@ -97119,6 +98466,7 @@ var require_auto_mode = __commonJS({
97119
98466
  exports2.resumeAutoJob = resumeAutoJob;
97120
98467
  exports2.cancelAutoJob = cancelAutoJob2;
97121
98468
  exports2.getAutoJobStatus = getAutoJobStatus;
98469
+ exports2.waitForAutoJob = waitForAutoJob;
97122
98470
  var db_1 = require_db();
97123
98471
  var builder_1 = require_builder();
97124
98472
  var AutoJob = class {
@@ -97330,6 +98678,20 @@ var require_auto_mode = __commonJS({
97330
98678
  function getAutoJobStatus() {
97331
98679
  return activeJob ? activeJob.snapshot() : null;
97332
98680
  }
98681
+ async function waitForAutoJob(jobId, onProgress, timeoutMs = 90 * 6e4) {
98682
+ const started = Date.now();
98683
+ for (; ; ) {
98684
+ const snap = activeJob && activeJob.id === jobId ? activeJob.snapshot() : null;
98685
+ if (!snap)
98686
+ return null;
98687
+ onProgress?.(snap);
98688
+ if (snap.state === "completed" || snap.state === "cancelled")
98689
+ return snap;
98690
+ if (Date.now() - started > timeoutMs)
98691
+ return snap;
98692
+ await new Promise((r) => setTimeout(r, 2e3));
98693
+ }
98694
+ }
97333
98695
  }
97334
98696
  });
97335
98697
 
@@ -97869,7 +99231,7 @@ var require_vercel_store = __commonJS({
97869
99231
  });
97870
99232
 
97871
99233
  // src/engine.ts
97872
- var db, chat, chatTools, autoMode, cms, bookingPreset, builder, vercelStore, agency, followup, invoicing, models, llm, initDatabase, getDb, chatWithTools, runTool, TOOLS, startAutoJob, cancelAutoJob, provisionCmsForLead, generatePreset, deployBookingOnly, deployStandaloneBooking, handoverSite, createBlobStore, refreshModels, refreshIfStale, listCachedModels, getPreferredModel, getAgencyProfile, setAgencyProfile, agencyConfigured, formatMoney, dueFollowUps, outreachStats, contactHistory, renderLetterPdf, listInvoicesRaw, checkClaudeCodeAuth, llmBackend, claudeBin, CLAUDE_CODE_MODELS, getClaudeCodeModel;
99234
+ var db, chat, chatTools, autoMode, cms, bookingPreset, builder, vercelStore, agency, followup, invoicing, models, llm, initDatabase, getDb, chatWithTools, runTool, TOOLS, startAutoJob, cancelAutoJob, provisionCmsForLead, generatePreset, deployBookingOnly, deployStandaloneBooking, handoverSite, addEditorToSite, createBlobStore, refreshModels, refreshIfStale, listCachedModels, getPreferredModel, getAgencyProfile, setAgencyProfile, agencyConfigured, formatMoney, dueFollowUps, outreachStats, contactHistory, renderLetterPdf, listInvoicesRaw, checkClaudeCodeAuth, llmBackend, claudeBin, CLAUDE_CODE_MODELS, getClaudeCodeModel;
97873
99235
  var init_engine = __esm({
97874
99236
  "src/engine.ts"() {
97875
99237
  "use strict";
@@ -97898,6 +99260,7 @@ var init_engine = __esm({
97898
99260
  deployBookingOnly = builder.deployBookingOnly;
97899
99261
  deployStandaloneBooking = builder.deployStandaloneBooking;
97900
99262
  handoverSite = builder.handoverSite;
99263
+ addEditorToSite = builder.addEditorToSite;
97901
99264
  createBlobStore = vercelStore.createBlobStore;
97902
99265
  refreshModels = models.refreshModels;
97903
99266
  refreshIfStale = models.refreshIfStale;
@@ -98365,7 +99728,7 @@ var require_package2 = __commonJS({
98365
99728
  "package.json"(exports2, module2) {
98366
99729
  module2.exports = {
98367
99730
  name: "sproutkit-code",
98368
- version: "1.9.1",
99731
+ version: "1.11.0",
98369
99732
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
98370
99733
  bin: {
98371
99734
  sproutkit: "./dist/index.js"
@@ -98443,6 +99806,31 @@ function makeInvokeIpc(emit2) {
98443
99806
  emit: emit2
98444
99807
  });
98445
99808
  }
99809
+ if (channel === "sites:addEditor") {
99810
+ const leadId = Number(payload);
99811
+ const db2 = getDb();
99812
+ const lead = db2.get("SELECT * FROM leads WHERE id = ?", [leadId]);
99813
+ if (!lead) throw new Error("Lead not found");
99814
+ if (!lead.site_url) throw new Error("Build the website first, then add the editor.");
99815
+ const vercelToken = getSetting("vercel_token");
99816
+ if (!vercelToken) throw new Error("Vercel token not set");
99817
+ const blobToken = await requireBlobToken(vercelToken, emit2, leadId);
99818
+ const result = await addEditorToSite({
99819
+ lead,
99820
+ anthropicKey: getSetting("anthropic_key"),
99821
+ vercelToken,
99822
+ blobToken,
99823
+ reuseAdminPassword: lead.admin_password ?? null,
99824
+ onProgress: (msg) => emit2("cms:progress", { leadId, msg })
99825
+ });
99826
+ db2.run("UPDATE leads SET site_url = ?, admin_url = ?, admin_password = ? WHERE id = ?", [
99827
+ result.url,
99828
+ result.admin?.url ?? null,
99829
+ result.admin?.password ?? null,
99830
+ leadId
99831
+ ]);
99832
+ return result;
99833
+ }
98446
99834
  if (channel === "cms:provision") {
98447
99835
  const leadId = Number(payload);
98448
99836
  const db2 = getDb();
@@ -100312,23 +101700,24 @@ async function cmdCms(argv) {
100312
101700
  return;
100313
101701
  }
100314
101702
  const status = json ? void 0 : new Status();
100315
- if (status) status.start(`Setting up CMS for lead #${id}`);
101703
+ if (status) status.start(`Adding the editor to lead #${id}'s site`);
100316
101704
  try {
100317
101705
  const result = await runTool("provision_cms", { id }, makeCtx({ json, status }));
100318
- if (status) status.done(true, result.publicUrl ? `CMS live at ${result.publicUrl}` : `CMS set up for #${id}`);
101706
+ if (status) status.done(true, result.publicUrl ? `Editor live at ${result.publicUrl}/edit` : `Editor added for #${id}`);
100319
101707
  autoSync();
100320
101708
  if (json) emitJson(result);
100321
- else sayAdminHandover(result.adminUrl, result.adminPassword);
101709
+ else sayAdminHandover(result.adminUrl, result.adminPassword, result.editUrl);
100322
101710
  } catch (e) {
100323
101711
  if (status) status.done(false, "CMS setup failed");
100324
101712
  json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
100325
101713
  process.exitCode = 1;
100326
101714
  }
100327
101715
  }
100328
- function sayAdminHandover(adminUrl, adminPassword) {
101716
+ function sayAdminHandover(adminUrl, adminPassword, editUrl) {
100329
101717
  if (!adminUrl || !adminPassword) return;
100330
101718
  say("");
100331
101719
  say(c.bold(" Client handover"));
101720
+ if (editUrl) say(` Edit page: ${editUrl} (tap text/photos to change them \u2014 works on a phone)`);
100332
101721
  say(` Admin panel: ${adminUrl}`);
100333
101722
  say(` Password: ${adminPassword}`);
100334
101723
  say(c.dim(" Send these to the client \u2014 they manage bookings and content there."));