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.
- package/dist/index.js +1437 -48
- 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">⋯</button>
|
|
34445
|
+
</div>
|
|
34446
|
+
<div id="menu">
|
|
34447
|
+
<a href="/admin">All settings — 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)" } },
|
|
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
|
-
|
|
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 {
|
|
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(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/'|'/g, "'").replace(/"/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
|
|
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: "
|
|
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 ?? `
|
|
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
|
-
|
|
97524
|
+
Would you be open to a quick chat this week?
|
|
96208
97525
|
|
|
96209
|
-
|
|
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 ?? `
|
|
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
|
-
|
|
97684
|
+
Best regards
|
|
96358
97685
|
|
|
96359
|
-
|
|
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" ?
|
|
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
|
|
96427
|
-
|
|
96428
|
-
|
|
96429
|
-
|
|
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("
|
|
96865
|
-
return
|
|
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 (
|
|
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.
|
|
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(`
|
|
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 ? `
|
|
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."));
|