sproutkit-code 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +803 -26
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -31819,6 +31819,8 @@ export default async function handler(req, res) {
31819
31819
  `;
31820
31820
  exports2.TRACK_SNIPPET = `<script>
31821
31821
  (function(){
31822
+ // The owner editing their own page in /edit isn't a visitor.
31823
+ if (location.search.indexOf("sk_edit=") > -1) return;
31822
31824
  function send(ev){
31823
31825
  try {
31824
31826
  var body = JSON.stringify({ event: ev });
@@ -33674,7 +33676,7 @@ var require_cms_runtime = __commonJS({
33674
33676
  };
33675
33677
  })();
33676
33678
  Object.defineProperty(exports2, "__esModule", { value: true });
33677
- 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;
33678
33680
  exports2.generateFriendlyPassword = generateFriendlyPassword;
33679
33681
  exports2.hashPassword = hashPassword;
33680
33682
  exports2.generateSessionSecret = generateSessionSecret;
@@ -34381,6 +34383,548 @@ export default async function handler(req, res) {
34381
34383
  res.status(500).json({ error: "upload_failed", message: String(e) });
34382
34384
  }
34383
34385
  }
34386
+ `;
34387
+ exports2.EDIT_INDEX_HTML = String.raw`<!DOCTYPE html>
34388
+ <html lang="en">
34389
+ <head>
34390
+ <meta charset="UTF-8" />
34391
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
34392
+ <meta name="robots" content="noindex" />
34393
+ <meta name="apple-mobile-web-app-capable" content="yes" />
34394
+ <meta name="mobile-web-app-capable" content="yes" />
34395
+ <meta name="theme-color" content="#0a0a0a" />
34396
+ <title>__EDIT_TITLE__</title>
34397
+ <style>
34398
+ :root { --accent: #22c55e; --ink: #0a0a0a; --muted: #6b7280; --line: #e5e5e0; }
34399
+ * { box-sizing: border-box; }
34400
+ html, body { margin: 0; height: 100%; overflow: hidden; background: #f4f4f1; color: var(--ink);
34401
+ font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif; -webkit-text-size-adjust: 100%; }
34402
+ #site { position: fixed; top: 0; left: 0; width: 100%; border: 0; display: block; background: #fff;
34403
+ height: calc(100% - 64px - env(safe-area-inset-bottom)); }
34404
+ #bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 5; display: flex; align-items: center; gap: 8px;
34405
+ min-height: calc(64px + env(safe-area-inset-bottom)); padding: 10px 12px calc(10px + env(safe-area-inset-bottom));
34406
+ background: #0a0a0a; color: #fff; }
34407
+ #status { flex: 1; min-width: 0; font-size: 13px; line-height: 1.3; }
34408
+ #status b { display: block; font-size: 14px; }
34409
+ #status span { display: block; color: #a1a1aa; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
34410
+ button { font: inherit; font-size: 14px; font-weight: 600; border: 0; border-radius: 10px; padding: 11px 16px;
34411
+ cursor: pointer; -webkit-tap-highlight-color: transparent; }
34412
+ .primary { background: var(--accent); color: #04130a; }
34413
+ .primary:disabled { opacity: 0.4; cursor: default; }
34414
+ .ghost { background: #27272a; color: #fff; }
34415
+ .icon { background: transparent; color: #fff; padding: 8px 10px; font-size: 20px; line-height: 1; }
34416
+ #menu { position: fixed; right: 10px; bottom: calc(72px + env(safe-area-inset-bottom)); z-index: 6; display: none;
34417
+ min-width: 230px; padding: 6px; background: #fff; border: 1px solid var(--line); border-radius: 12px;
34418
+ box-shadow: 0 12px 32px rgba(0,0,0,0.18); }
34419
+ #menu a, #menu button { display: block; width: 100%; padding: 12px 14px; border-radius: 8px; background: transparent;
34420
+ color: var(--ink); text-align: left; text-decoration: none; font-size: 14px; font-weight: 500; }
34421
+ #menu a:hover, #menu button:hover { background: #f4f4f1; }
34422
+ #sheet { position: fixed; inset: 0; z-index: 10; display: none; align-items: center; justify-content: center;
34423
+ padding: 16px; background: rgba(10,10,10,0.55); }
34424
+ #sheet form { width: 100%; max-width: 360px; padding: 24px; background: #fff; border-radius: 16px; }
34425
+ #sheet h1 { margin: 0 0 4px; font-size: 20px; letter-spacing: -0.01em; }
34426
+ #sheet p { margin: 0 0 16px; color: var(--muted); font-size: 14px; }
34427
+ #sheet input { width: 100%; margin-bottom: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 10px;
34428
+ font: inherit; font-size: 16px; }
34429
+ #sheet button { width: 100%; }
34430
+ #err { min-height: 1em; margin-top: 10px; color: #b91c1c; font-size: 13px; }
34431
+ #toast { position: fixed; left: 50%; bottom: calc(76px + env(safe-area-inset-bottom)); z-index: 7;
34432
+ max-width: calc(100% - 32px); transform: translateX(-50%); padding: 10px 16px; border-radius: 999px;
34433
+ background: #fff; color: var(--ink); border: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0,0,0,0.15);
34434
+ font-size: 13px; font-weight: 500; text-align: center; opacity: 0; transition: opacity 0.2s; pointer-events: none; }
34435
+ #toast.on { opacity: 1; }
34436
+ </style>
34437
+ </head>
34438
+ <body>
34439
+ <iframe id="site" title="Your website"></iframe>
34440
+ <div id="bar">
34441
+ <div id="status"><b>Edit mode</b><span>Loading your site…</span></div>
34442
+ <button id="discard" class="ghost" hidden>Undo</button>
34443
+ <button id="save" class="primary" disabled>Save</button>
34444
+ <button id="more" class="icon" aria-label="More options">&#8943;</button>
34445
+ </div>
34446
+ <div id="menu">
34447
+ <a href="/admin">All settings &mdash; services, hours, photos</a>
34448
+ <a href="/" target="_blank" rel="noopener">View live site</a>
34449
+ <button id="signout" type="button">Sign out</button>
34450
+ </div>
34451
+ <div id="sheet">
34452
+ <form id="login">
34453
+ <h1>Edit your website</h1>
34454
+ <p>Enter your password to start editing.</p>
34455
+ <input id="pw" type="password" autocomplete="current-password" placeholder="Password" />
34456
+ <button class="primary" type="submit" id="go">Sign in</button>
34457
+ <div id="err"></div>
34458
+ </form>
34459
+ </div>
34460
+ <input id="file" type="file" accept="image/*" hidden />
34461
+ <div id="toast"></div>
34462
+ <script src="https://unpkg.com/browser-image-compression@2.0.2/dist/browser-image-compression.js" crossorigin></script>
34463
+ <script>
34464
+ (function () {
34465
+ var $ = function (id) { return document.getElementById(id); };
34466
+ var frame = $("site"), saveBtn = $("save"), discardBtn = $("discard"), statusEl = $("status");
34467
+ var menu = $("menu"), sheet = $("sheet"), pw = $("pw"), err = $("err"), fileInput = $("file");
34468
+
34469
+ // Fields the owner can change in place. Reviews come live from Google and
34470
+ // the theme is a design choice, so neither is offered here.
34471
+ var TEXT_PATHS = /^(business\.(name|tagline)|hero\.(headline|subheadline|ctaText)|about|services\.\d+\.(title|description|price)|contact\.(address|phone|email)|contact\.hours\.\d+)$/;
34472
+ var MULTILINE = /^(about|hero\.subheadline|services\.\d+\.description)$/;
34473
+ var HTML_NS = "http://www.w3.org/1999/xhtml";
34474
+
34475
+ var base = null; // content as last saved
34476
+ var draft = null; // content including unsaved edits
34477
+ var dirty = {}; // path -> true while it differs from base
34478
+ var editing = null, saving = false, loginResolve = null, photoIdx = null;
34479
+ var chipFor = new Map(), observer = null, scanTimer = null;
34480
+
34481
+ function norm(s) { return String(s == null ? "" : s).replace(/\s+/g, " ").trim(); }
34482
+ function clone(o) { return JSON.parse(JSON.stringify(o)); }
34483
+ function get(obj, path) {
34484
+ return path.split(".").reduce(function (o, k) { return o == null ? undefined : o[k]; }, obj);
34485
+ }
34486
+ function put(obj, path, v) {
34487
+ var ks = path.split("."), o = obj;
34488
+ for (var i = 0; i < ks.length - 1; i++) { o = o[ks[i]]; if (o == null) return; }
34489
+ o[ks[ks.length - 1]] = v;
34490
+ }
34491
+ function leaves(obj, prefix, out) {
34492
+ if (typeof obj === "string") { if (TEXT_PATHS.test(prefix)) out.push(prefix); }
34493
+ else if (obj && typeof obj === "object") {
34494
+ Object.keys(obj).forEach(function (k) { leaves(obj[k], prefix ? prefix + "." + k : k, out); });
34495
+ }
34496
+ return out;
34497
+ }
34498
+
34499
+ var toastTimer = null;
34500
+ function toast(msg, ms) {
34501
+ var t = $("toast");
34502
+ t.textContent = msg; t.classList.add("on");
34503
+ clearTimeout(toastTimer);
34504
+ toastTimer = setTimeout(function () { t.classList.remove("on"); }, ms || 2600);
34505
+ }
34506
+
34507
+ function updateBar() {
34508
+ var n = Object.keys(dirty).length;
34509
+ saveBtn.disabled = n === 0 || saving;
34510
+ saveBtn.textContent = saving ? "Saving…" : "Save";
34511
+ discardBtn.hidden = n === 0 || saving;
34512
+ statusEl.innerHTML = n
34513
+ ? "<b>" + n + " unsaved change" + (n > 1 ? "s" : "") + "</b><span>Press Save to put them live</span>"
34514
+ : "<b>Edit mode</b><span>Tap any outlined text or photo to change it</span>";
34515
+ }
34516
+
34517
+ function refreshDirty(paths) {
34518
+ paths.forEach(function (p) {
34519
+ var a = get(draft, p), b = get(base, p);
34520
+ if (p.indexOf("photos.") === 0 ? a !== b : norm(a) !== norm(b)) dirty[p] = true;
34521
+ else delete dirty[p];
34522
+ });
34523
+ updateBar();
34524
+ }
34525
+
34526
+ // ---- Login ----------------------------------------------------------------
34527
+ function requireLogin() {
34528
+ return new Promise(function (resolve) {
34529
+ loginResolve = resolve;
34530
+ err.textContent = "";
34531
+ sheet.style.display = "flex";
34532
+ setTimeout(function () { pw.focus(); }, 50);
34533
+ });
34534
+ }
34535
+ $("login").addEventListener("submit", async function (e) {
34536
+ e.preventDefault();
34537
+ if (!pw.value) return;
34538
+ $("go").disabled = true; err.textContent = "";
34539
+ try {
34540
+ var r = await fetch("/api/admin/login", {
34541
+ method: "POST", credentials: "same-origin",
34542
+ headers: { "Content-Type": "application/json" },
34543
+ body: JSON.stringify({ password: pw.value })
34544
+ });
34545
+ if (r.status === 429) {
34546
+ var lock = await r.json().catch(function () { return {}; });
34547
+ err.textContent = "Too many tries. Wait " + Math.ceil((lock.retryAfter || 900) / 60) + " minutes and try again.";
34548
+ } else if (!r.ok) {
34549
+ err.textContent = r.status === 401 ? "That password didn't work." : "Couldn't sign in (" + r.status + ").";
34550
+ } else {
34551
+ var who = await fetch("/api/admin/whoami", { credentials: "same-origin" });
34552
+ if (!who.ok) {
34553
+ err.textContent = "Signed in, but your browser blocked the login cookie. Turn off strict tracking protection for this site.";
34554
+ } else {
34555
+ pw.value = ""; sheet.style.display = "none";
34556
+ if (loginResolve) { loginResolve(); loginResolve = null; }
34557
+ }
34558
+ }
34559
+ } catch (x) {
34560
+ err.textContent = "No connection. Check your signal and try again.";
34561
+ }
34562
+ $("go").disabled = false;
34563
+ });
34564
+
34565
+ // ---- Matching the page to content.json ------------------------------------
34566
+ // A field may render as one element or, when it has blank-line breaks, one
34567
+ // element per paragraph. "about#1" means the second paragraph of about.
34568
+ function paras(v) { return String(v == null ? "" : v).split(/\n\s*\n/); }
34569
+ function readField(obj, ref) {
34570
+ var at = ref.indexOf("#");
34571
+ if (at < 0) return get(obj, ref);
34572
+ return paras(get(obj, ref.slice(0, at)))[Number(ref.slice(at + 1))];
34573
+ }
34574
+ function writeField(obj, ref, v) {
34575
+ var at = ref.indexOf("#");
34576
+ if (at < 0) return put(obj, ref, v);
34577
+ var path = ref.slice(0, at), list = paras(get(obj, path));
34578
+ list[Number(ref.slice(at + 1))] = v;
34579
+ put(obj, path, list.join("\n\n"));
34580
+ }
34581
+ function fieldOf(ref) { var at = ref.indexOf("#"); return at < 0 ? ref : ref.slice(0, at); }
34582
+
34583
+ function textIndex() {
34584
+ // Saved values too, so a page that still shows old text (a second page of
34585
+ // a multipage site, say) is still recognised — then shown with the draft.
34586
+ var map = {};
34587
+ function add(t, ref) {
34588
+ t = norm(t);
34589
+ if (t.length < 2) return;
34590
+ map[t] = map[t] || [];
34591
+ if (map[t].indexOf(ref) < 0) map[t].push(ref);
34592
+ }
34593
+ leaves(draft, "", []).forEach(function (p) {
34594
+ [draft, base].forEach(function (src) {
34595
+ var v = get(src, p);
34596
+ add(v, p);
34597
+ var list = paras(v);
34598
+ if (list.length > 1) list.forEach(function (para, i) { add(para, p + "#" + i); });
34599
+ });
34600
+ });
34601
+ return map;
34602
+ }
34603
+
34604
+ // Text, line breaks and inline styling (a coloured word in a headline) are
34605
+ // fine to edit in place; anything structural is not.
34606
+ var INLINE = /^(BR|EM|STRONG|B|I|U|SPAN|SMALL|MARK|SUP|SUB)$/;
34607
+ function editableShape(el, text) {
34608
+ for (var i = 0; i < el.children.length; i++) {
34609
+ var c = el.children[i];
34610
+ if (!INLINE.test(c.tagName) || c.children.length > 0) return false;
34611
+ if (norm(c.textContent) === text) return false; // the child is the better match
34612
+ }
34613
+ return true;
34614
+ }
34615
+
34616
+ function injectStyle(doc) {
34617
+ if (doc.getElementById("sk-edit-style")) return;
34618
+ var s = doc.createElement("style");
34619
+ s.id = "sk-edit-style";
34620
+ s.textContent =
34621
+ "[data-sk-path]{outline:1.5px dashed rgba(34,197,94,.9)!important;outline-offset:3px;cursor:text!important;border-radius:2px;-webkit-user-select:text;user-select:text}" +
34622
+ "[data-sk-path].sk-changed{outline-color:#f59e0b!important}" +
34623
+ "[data-sk-path][contenteditable]{outline:2px solid #22c55e!important;background:rgba(34,197,94,.12)!important}" +
34624
+ ".sk-chip{position:absolute;z-index:2147483646;padding:9px 12px;border-radius:999px;border:1px solid rgba(255,255,255,.3);background:rgba(10,10,10,.85);color:#fff;font:600 12px/1 Inter,system-ui,sans-serif;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.3)}";
34625
+ doc.head.appendChild(s);
34626
+ }
34627
+
34628
+ function scan() {
34629
+ var doc = frame.contentDocument;
34630
+ if (!doc || !doc.body || !draft || editing) return;
34631
+ injectStyle(doc);
34632
+ var root = doc.getElementById("root") || doc.body;
34633
+ var map = textIndex();
34634
+ var els = root.querySelectorAll("*");
34635
+ for (var i = 0; i < els.length; i++) {
34636
+ var el = els[i];
34637
+ if (el.namespaceURI !== HTML_NS || /^(SCRIPT|STYLE|NOSCRIPT|IFRAME|INPUT|TEXTAREA|SELECT|OPTION)$/.test(el.tagName)) continue;
34638
+ var text = norm(el.textContent);
34639
+ var paths = map[text] && editableShape(el, text) ? map[text] : null;
34640
+ if (paths) {
34641
+ el.setAttribute("data-sk-path", paths.join("|"));
34642
+ var want = readField(draft, paths[0]);
34643
+ if (norm(want) !== text) el.textContent = want;
34644
+ el.classList.toggle("sk-changed", paths.some(function (p) { return dirty[fieldOf(p)]; }));
34645
+ } else if (el.hasAttribute("data-sk-path")) {
34646
+ el.removeAttribute("data-sk-path");
34647
+ el.classList.remove("sk-changed");
34648
+ }
34649
+ }
34650
+ placeChips(doc);
34651
+ }
34652
+
34653
+ function scheduleScan() {
34654
+ clearTimeout(scanTimer);
34655
+ scanTimer = setTimeout(scan, 250);
34656
+ }
34657
+
34658
+ // ---- Photos ---------------------------------------------------------------
34659
+ function abs(u, doc) { try { return new URL(u, doc.baseURI).href; } catch (x) { return u; } }
34660
+ function shownUrl(el, win) {
34661
+ if (el.tagName === "IMG") return el.currentSrc || el.src;
34662
+ var m = (win.getComputedStyle(el).backgroundImage || "").match(/url\(["']?(.*?)["']?\)/);
34663
+ return m ? m[1] : null;
34664
+ }
34665
+ function showPhoto(el, url) {
34666
+ if (el.tagName === "IMG") { el.removeAttribute("srcset"); el.src = url; return; }
34667
+ var cur = el.style.backgroundImage;
34668
+ el.style.backgroundImage = /url\(/.test(cur)
34669
+ ? cur.replace(/url\((["']?)(.*?)\1\)/, 'url("' + url + '")')
34670
+ : 'url("' + url + '")';
34671
+ }
34672
+
34673
+ function placeChips(doc) {
34674
+ var win = frame.contentWindow;
34675
+ var known = {};
34676
+ (base.photos || []).forEach(function (p, i) { if (p && p.url) known[abs(p.url, doc)] = i; });
34677
+ (draft.photos || []).forEach(function (p, i) { if (p && p.url) known[abs(p.url, doc)] = i; });
34678
+ var seen = new Set();
34679
+ var els = doc.querySelectorAll('#root img, #root [style*="url("]');
34680
+ for (var i = 0; i < els.length; i++) {
34681
+ var el = els[i], u = shownUrl(el, win);
34682
+ var idx = u ? known[abs(u, doc)] : undefined;
34683
+ if (idx === undefined) continue;
34684
+ var want = draft.photos[idx] && draft.photos[idx].url;
34685
+ if (want && abs(want, doc) !== abs(u, doc)) showPhoto(el, want);
34686
+ var r = el.getBoundingClientRect(), cs = win.getComputedStyle(el);
34687
+ var chip = chipFor.get(el);
34688
+ if (r.width < 80 || r.height < 60 || cs.visibility === "hidden" || Number(cs.opacity) < 0.1) {
34689
+ if (chip) chip.style.display = "none";
34690
+ seen.add(el);
34691
+ continue;
34692
+ }
34693
+ if (!chip) {
34694
+ chip = doc.createElement("button");
34695
+ chip.type = "button";
34696
+ chip.className = "sk-chip";
34697
+ chip.setAttribute("data-sk-ui", "1");
34698
+ chip.textContent = "Change photo";
34699
+ chip.addEventListener("click", function (e) {
34700
+ e.preventDefault(); e.stopPropagation();
34701
+ photoIdx = Number(e.currentTarget.getAttribute("data-idx"));
34702
+ fileInput.value = "";
34703
+ fileInput.click();
34704
+ });
34705
+ doc.body.appendChild(chip);
34706
+ chipFor.set(el, chip);
34707
+ }
34708
+ chip.setAttribute("data-idx", String(idx));
34709
+ chip.style.display = "";
34710
+ chip.style.top = (r.top + win.scrollY + 10) + "px";
34711
+ chip.style.left = (r.left + win.scrollX + 10) + "px";
34712
+ seen.add(el);
34713
+ }
34714
+ chipFor.forEach(function (chip, el) {
34715
+ if (!seen.has(el)) { chip.remove(); chipFor.delete(el); }
34716
+ });
34717
+ }
34718
+
34719
+ fileInput.addEventListener("change", async function () {
34720
+ var f = fileInput.files && fileInput.files[0];
34721
+ if (!f || photoIdx === null) return;
34722
+ var idx = photoIdx;
34723
+ toast("Uploading photo…", 60000);
34724
+ try {
34725
+ var blob = f;
34726
+ if (window.imageCompression) {
34727
+ blob = await imageCompression(f, { maxSizeMB: 1.5, maxWidthOrHeight: 1600, fileType: "image/webp", useWebWorker: true });
34728
+ }
34729
+ var send = function () {
34730
+ return fetch("/api/admin/upload", {
34731
+ method: "POST", credentials: "same-origin",
34732
+ headers: { "Content-Type": blob.type || "image/webp" }, body: blob
34733
+ });
34734
+ };
34735
+ var r = await send();
34736
+ if (r.status === 401) { await requireLogin(); r = await send(); }
34737
+ if (r.status === 413) throw new Error("that photo is too big");
34738
+ if (!r.ok) throw new Error("upload failed (" + r.status + ")");
34739
+ var data = await r.json();
34740
+ draft.photos[idx] = { url: data.url, alt: (draft.photos[idx] && draft.photos[idx].alt) || "" };
34741
+ refreshDirty(["photos." + idx]);
34742
+ scan();
34743
+ toast("Photo changed. Press Save to put it live.");
34744
+ } catch (x) {
34745
+ toast("Couldn't change the photo: " + (x && x.message ? x.message : x), 4000);
34746
+ }
34747
+ });
34748
+
34749
+ // ---- Editing text ---------------------------------------------------------
34750
+ function pathsOf(el) { return (el.getAttribute("data-sk-path") || "").split("|").filter(Boolean); }
34751
+
34752
+ function startEdit(el) {
34753
+ if (editing === el) return;
34754
+ if (editing) editing.blur();
34755
+ editing = el;
34756
+ try { el.contentEditable = "plaintext-only"; } catch (x) { /* older browsers */ }
34757
+ if (el.contentEditable !== "plaintext-only") el.contentEditable = "true";
34758
+ el.spellcheck = true;
34759
+ el.addEventListener("input", onInput);
34760
+ el.addEventListener("keydown", onKey);
34761
+ el.addEventListener("paste", onPaste);
34762
+ el.addEventListener("blur", onBlur);
34763
+ el.focus();
34764
+ var doc = el.ownerDocument, sel = doc.getSelection(), range = doc.createRange();
34765
+ range.selectNodeContents(el); range.collapse(false);
34766
+ sel.removeAllRanges(); sel.addRange(range);
34767
+ }
34768
+
34769
+ function onInput(e) {
34770
+ var el = e.currentTarget, paths = pathsOf(el);
34771
+ var v = el.innerText.replace(/\u00a0/g, " ");
34772
+ // A single paragraph can't hold a paragraph break — it would split on reload.
34773
+ if (!MULTILINE.test(paths[0]) || paths[0].indexOf("#") > -1) v = v.replace(/\s*\n\s*/g, " ");
34774
+ v = v.trim();
34775
+ paths.forEach(function (p) { writeField(draft, p, v); });
34776
+ // The same text shown elsewhere on the page (name in the nav and footer).
34777
+ var others = el.ownerDocument.querySelectorAll("[data-sk-path]");
34778
+ for (var i = 0; i < others.length; i++) {
34779
+ if (others[i] !== el && others[i].getAttribute("data-sk-path") === el.getAttribute("data-sk-path")) others[i].textContent = v;
34780
+ }
34781
+ refreshDirty(paths.map(fieldOf));
34782
+ }
34783
+
34784
+ function onKey(e) {
34785
+ var el = e.currentTarget;
34786
+ var ref = pathsOf(el)[0];
34787
+ if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey && (!MULTILINE.test(ref) || ref.indexOf("#") > -1))) {
34788
+ e.preventDefault();
34789
+ el.blur();
34790
+ }
34791
+ }
34792
+
34793
+ function onPaste(e) {
34794
+ if (e.currentTarget.contentEditable === "plaintext-only") return;
34795
+ e.preventDefault();
34796
+ var text = (e.clipboardData || window.clipboardData).getData("text/plain");
34797
+ e.currentTarget.ownerDocument.execCommand("insertText", false, text);
34798
+ }
34799
+
34800
+ function onBlur(e) {
34801
+ var el = e.currentTarget;
34802
+ el.removeAttribute("contenteditable");
34803
+ el.removeEventListener("input", onInput);
34804
+ el.removeEventListener("keydown", onKey);
34805
+ el.removeEventListener("paste", onPaste);
34806
+ el.removeEventListener("blur", onBlur);
34807
+ el.classList.toggle("sk-changed", pathsOf(el).some(function (p) { return dirty[fieldOf(p)]; }));
34808
+ editing = null;
34809
+ scheduleScan();
34810
+ }
34811
+
34812
+ // ---- The site frame -------------------------------------------------------
34813
+ function onFrameClick(e) {
34814
+ var t = e.target;
34815
+ if (t && t.nodeType !== 1) t = t.parentElement;
34816
+ if (!t || !t.closest || t.closest("[data-sk-ui]")) return;
34817
+ var field = t.closest("[data-sk-path]");
34818
+ if (field) {
34819
+ // Also stops the site's own handler, so tapping a button's label edits
34820
+ // the label instead of pressing the button.
34821
+ e.preventDefault(); e.stopPropagation();
34822
+ startEdit(field);
34823
+ return;
34824
+ }
34825
+ var a = t.closest("a[href]");
34826
+ if (!a) return;
34827
+ var url = null;
34828
+ try { url = new URL(a.getAttribute("href"), a.ownerDocument.baseURI); } catch (x) {}
34829
+ var samePage = url && url.origin === location.origin && !a.target && !/^\/(admin|edit|api|book)(\/|$)/.test(url.pathname);
34830
+ if (samePage) return; // moving between the site's own pages is fine
34831
+ e.preventDefault(); e.stopPropagation();
34832
+ toast("Links don't open while you're editing");
34833
+ }
34834
+
34835
+ function onFrameLoad() {
34836
+ var doc = frame.contentDocument;
34837
+ if (!doc || !doc.body) return;
34838
+ chipFor = new Map();
34839
+ editing = null;
34840
+ doc.addEventListener("click", onFrameClick, true);
34841
+ doc.addEventListener("submit", function (e) {
34842
+ e.preventDefault(); e.stopPropagation();
34843
+ toast("Forms don't send while you're editing");
34844
+ }, true);
34845
+ if (observer) observer.disconnect();
34846
+ observer = new MutationObserver(scheduleScan);
34847
+ observer.observe(doc.getElementById("root") || doc.body, {
34848
+ childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ["src", "style"]
34849
+ });
34850
+ frame.contentWindow.addEventListener("resize", scheduleScan);
34851
+ scan();
34852
+ }
34853
+
34854
+ // ---- Save / undo ----------------------------------------------------------
34855
+ async function save() {
34856
+ if (editing) editing.blur();
34857
+ if (!Object.keys(dirty).length) return;
34858
+ saving = true; updateBar();
34859
+ try {
34860
+ var send = function () {
34861
+ return fetch("/api/content", {
34862
+ method: "POST", credentials: "same-origin",
34863
+ headers: { "Content-Type": "application/json" }, body: JSON.stringify(draft)
34864
+ });
34865
+ };
34866
+ var r = await send();
34867
+ if (r.status === 401) { await requireLogin(); r = await send(); }
34868
+ if (!r.ok) throw new Error("the server said " + r.status);
34869
+ var d = await r.json();
34870
+ base = d.content || clone(draft);
34871
+ draft = clone(base);
34872
+ dirty = {};
34873
+ toast("Saved. Visitors will see it within a minute.", 3500);
34874
+ } catch (x) {
34875
+ toast("Couldn't save: " + (x && x.message ? x.message : x) + ". Your changes are still here.", 5000);
34876
+ }
34877
+ saving = false;
34878
+ updateBar();
34879
+ scan();
34880
+ }
34881
+
34882
+ saveBtn.addEventListener("click", save);
34883
+ discardBtn.addEventListener("click", function () {
34884
+ if (!confirm("Undo all unsaved changes?")) return;
34885
+ draft = clone(base); dirty = {};
34886
+ updateBar();
34887
+ frame.contentWindow.location.reload();
34888
+ });
34889
+ $("more").addEventListener("click", function (e) {
34890
+ e.stopPropagation();
34891
+ menu.style.display = menu.style.display === "block" ? "none" : "block";
34892
+ });
34893
+ document.addEventListener("click", function () { menu.style.display = "none"; });
34894
+ $("signout").addEventListener("click", async function () {
34895
+ if (Object.keys(dirty).length && !confirm("You have unsaved changes. Sign out anyway?")) return;
34896
+ dirty = {};
34897
+ await fetch("/api/admin/logout", { method: "POST", credentials: "same-origin" }).catch(function () {});
34898
+ location.href = "/";
34899
+ });
34900
+ window.addEventListener("beforeunload", function (e) {
34901
+ if (Object.keys(dirty).length) { e.preventDefault(); e.returnValue = ""; }
34902
+ });
34903
+
34904
+ async function init() {
34905
+ frame.addEventListener("load", onFrameLoad);
34906
+ frame.src = "/?sk_edit=1";
34907
+ var who = await fetch("/api/admin/whoami", { credentials: "same-origin" }).catch(function () { return null; });
34908
+ if (!who || !who.ok) await requireLogin();
34909
+ var r = await fetch("/api/content?t=" + Date.now(), { cache: "no-store" }).catch(function () { return null; });
34910
+ if (!r || !r.ok) {
34911
+ statusEl.innerHTML = "<b>Editing isn't switched on</b><span>Ask whoever built your site to turn on the editor.</span>";
34912
+ return;
34913
+ }
34914
+ base = await r.json();
34915
+ if (!Array.isArray(base.photos)) base.photos = [];
34916
+ draft = clone(base);
34917
+ updateBar();
34918
+ scan();
34919
+ // Layout settles as fonts and images load; keep the photo chips in place.
34920
+ setInterval(function () { if (!editing) scan(); }, 1500);
34921
+ toast("Tap anything with a dashed outline to change it", 4000);
34922
+ }
34923
+ init();
34924
+ })();
34925
+ </script>
34926
+ </body>
34927
+ </html>
34384
34928
  `;
34385
34929
  exports2.ADMIN_INDEX_HTML = `<!DOCTYPE html>
34386
34930
  <html lang="en">
@@ -34620,7 +35164,10 @@ export default async function handler(req, res) {
34620
35164
  h("div", { className: "bar" },
34621
35165
  h("div", null,
34622
35166
  h("div", { style: { fontSize: 15, fontWeight: 600 } }, "Editing " + (content.business?.name || "your site")),
34623
- h("div", { style: { fontSize: 12, color: "var(--muted)" } }, h("a", { href: "/" }, "View site \u2192"))
35167
+ h("div", { style: { fontSize: 12, color: "var(--muted)" } },
35168
+ h("a", { href: "/" }, "View site \u2192"), " \xB7 ",
35169
+ h("a", { href: "/edit" }, "Edit on the page \u2192")
35170
+ )
34624
35171
  ),
34625
35172
  h("button", { className: "primary", onClick: save, disabled: saving[0] }, saving[0] ? "Saving\u2026" : "Save changes")
34626
35173
  ),
@@ -81053,6 +81600,7 @@ var require_builder = __commonJS({
81053
81600
  exports2.deployBookingOnly = deployBookingOnly2;
81054
81601
  exports2.deployStandaloneBooking = deployStandaloneBooking2;
81055
81602
  exports2.handoverSite = handoverSite2;
81603
+ exports2.addEditorToSite = addEditorToSite2;
81056
81604
  var sdk_1 = __importDefault(require_sdk());
81057
81605
  var axios_1 = __importDefault(require_axios());
81058
81606
  var crypto_1 = require("crypto");
@@ -81164,6 +81712,11 @@ var require_builder = __commonJS({
81164
81712
  const title = p.name ? `${p.name} \u2014 site manager` : "Site editor";
81165
81713
  return cms_runtime_1.ADMIN_INDEX_HTML.replace("__ADMIN_TITLE__", title.replace(/[<>&]/g, ""));
81166
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
+ }
81167
81720
  function bannerFor(lead) {
81168
81721
  let handedOver = false;
81169
81722
  try {
@@ -81861,7 +82414,9 @@ export default defineConfig({ plugins: [react()] });`;
81861
82414
  const vercelJson = JSON.stringify({
81862
82415
  rewrites: [
81863
82416
  { source: "/admin", destination: "/admin/index.html" },
81864
- { 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" }
81865
82420
  ]
81866
82421
  });
81867
82422
  cmsExtraFiles = [
@@ -81873,7 +82428,8 @@ export default defineConfig({ plugins: [react()] });`;
81873
82428
  { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
81874
82429
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" },
81875
82430
  { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
81876
- { 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" }
81877
82433
  ];
81878
82434
  cmsAdmin = { url: "", password: adminPassword };
81879
82435
  }
@@ -81973,6 +82529,8 @@ export default defineConfig({ plugins: [react()] });`;
81973
82529
  rewrites: [
81974
82530
  { source: "/admin", destination: "/admin/index.html" },
81975
82531
  { source: "/admin/", destination: "/admin/index.html" },
82532
+ { source: "/edit", destination: "/edit/index.html" },
82533
+ { source: "/edit/", destination: "/edit/index.html" },
81976
82534
  { source: "/book", destination: "/book/index.html" },
81977
82535
  { source: "/book/", destination: "/book/index.html" }
81978
82536
  ]
@@ -81985,7 +82543,7 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
81985
82543
  const viteConfigCms = `import { defineConfig } from 'vite';
81986
82544
  import react from '@vitejs/plugin-react';
81987
82545
  export default defineConfig({ plugins: [react()] });`;
81988
- 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" });
81989
82547
  if (!filesToRedeploy.some((f) => f.file === "src/App.jsx")) {
81990
82548
  throw new Error("Couldn't fetch src/App.jsx from the existing deployment. Try Regenerate website first, then Redeploy CMS.");
81991
82549
  }
@@ -82121,6 +82679,8 @@ export default defineConfig({ plugins: [react()] });`;
82121
82679
  rewrites: [
82122
82680
  { source: "/admin", destination: "/admin/index.html" },
82123
82681
  { source: "/admin/", destination: "/admin/index.html" },
82682
+ { source: "/edit", destination: "/edit/index.html" },
82683
+ { source: "/edit/", destination: "/edit/index.html" },
82124
82684
  { source: "/book", destination: "/book/index.html" },
82125
82685
  { source: "/book/", destination: "/book/index.html" }
82126
82686
  ]
@@ -82141,6 +82701,7 @@ export default defineConfig({ plugins: [react()] });`;
82141
82701
  // Refresh the CMS admin app (contains the Bookings tab now)
82142
82702
  { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
82143
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" },
82144
82705
  { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" },
82145
82706
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
82146
82707
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
@@ -82206,6 +82767,7 @@ export default defineConfig({ plugins: [react()] });`;
82206
82767
  // admin the CMS sites use — it already has a Bookings tab.
82207
82768
  { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
82208
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" },
82209
82771
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
82210
82772
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
82211
82773
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
@@ -82218,7 +82780,9 @@ export default defineConfig({ plugins: [react()] });`;
82218
82780
  data: JSON.stringify({
82219
82781
  rewrites: [
82220
82782
  { source: "/admin", destination: "/admin/index.html" },
82221
- { 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" }
82222
82786
  ]
82223
82787
  }),
82224
82788
  encoding: "utf-8"
@@ -82388,12 +82952,14 @@ export default defineConfig({ plugins: [react()] });`,
82388
82952
  rewrites: [
82389
82953
  { source: "/admin", destination: "/admin/index.html" },
82390
82954
  { source: "/admin/", destination: "/admin/index.html" },
82955
+ { source: "/edit", destination: "/edit/index.html" },
82956
+ { source: "/edit/", destination: "/edit/index.html" },
82391
82957
  { source: "/book", destination: "/book/index.html" },
82392
82958
  { source: "/book/", destination: "/book/index.html" }
82393
82959
  ]
82394
82960
  }),
82395
82961
  encoding: "utf-8"
82396
- }, { 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" });
82397
82963
  const env = {
82398
82964
  BLOB_READ_WRITE_TOKEN: blobToken,
82399
82965
  ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
@@ -82416,6 +82982,132 @@ export default defineConfig({ plugins: [react()] });`,
82416
82982
  onProgress(`Live at ${url}`);
82417
82983
  return { url };
82418
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
+ }
82419
83111
  function hasBookLink(code) {
82420
83112
  return /(href\s*=\s*["'`{]*\s*["'`]?\/book\/?["'`}]?)|(location(?:\.href)?\s*=\s*["'`]\/book["'`])/i.test(code);
82421
83113
  }
@@ -82670,6 +83362,7 @@ var require_places = __commonJS({
82670
83362
  async function searchOneType(args) {
82671
83363
  const all = [];
82672
83364
  let pageToken;
83365
+ let error;
82673
83366
  for (let page = 0; page < 3; page++) {
82674
83367
  const body = {
82675
83368
  textQuery: `${args.type} in ${args.city}`,
@@ -82696,11 +83389,13 @@ var require_places = __commonJS({
82696
83389
  pageToken = r.data.nextPageToken;
82697
83390
  if (!pageToken)
82698
83391
  break;
82699
- } catch {
83392
+ } catch (e) {
83393
+ const ax = e;
83394
+ error = ax.response?.data?.error?.message ?? ax.message ?? "Places request failed";
82700
83395
  break;
82701
83396
  }
82702
83397
  }
82703
- return { type: args.type, places: all };
83398
+ return { type: args.type, places: all, error };
82704
83399
  }
82705
83400
  function processRawPlaces(type, places, center, radiusMeters, includeWithWebsite) {
82706
83401
  const out = [];
@@ -82738,16 +83433,28 @@ var require_places = __commonJS({
82738
83433
  return out;
82739
83434
  }
82740
83435
  async function searchPlacesStream(args, onBatch) {
83436
+ const stats = { scanned: 0, withWebsite: 0, outsideArea: 0, errors: [] };
82741
83437
  await Promise.all(args.types.map(async (type) => {
82742
- const { places } = await searchOneType({
83438
+ const { places, error } = await searchOneType({
82743
83439
  apiKey: args.apiKey,
82744
83440
  city: args.city,
82745
83441
  type,
82746
83442
  center: args.center,
82747
83443
  radiusMeters: args.radiusMeters
82748
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
+ }
82749
83455
  await onBatch(processRawPlaces(type, places, args.center, args.radiusMeters, args.includeWithWebsite));
82750
83456
  }));
83457
+ return stats;
82751
83458
  }
82752
83459
  async function searchPlaces(args) {
82753
83460
  const batches = await Promise.all(args.types.map((type) => searchOneType({
@@ -82835,15 +83542,24 @@ var require_places = __commonJS({
82835
83542
  if (!query.trim())
82836
83543
  return [];
82837
83544
  const r = await axios_1.default.get("https://nominatim.openstreetmap.org/search", {
82838
- params: { q: query, format: "json", limit, addressdetails: 1 },
83545
+ params: { q: query, format: "json", limit, addressdetails: 1, "accept-language": "en" },
82839
83546
  headers: { "User-Agent": "Sproutio/0.1 (desktop app)" }
82840
83547
  });
82841
83548
  return (r.data ?? []).map((d) => ({
82842
83549
  lat: parseFloat(d.lat),
82843
83550
  lng: parseFloat(d.lon),
82844
- label: d.display_name
83551
+ label: d.display_name,
83552
+ short: shortPlaceName(d.address, d.display_name)
82845
83553
  }));
82846
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
+ }
82847
83563
  async function geocodeReverse(lat, lng) {
82848
83564
  const r = await axios_1.default.get("https://nominatim.openstreetmap.org/reverse", {
82849
83565
  params: { lat, lon: lng, format: "json", addressdetails: 1, zoom: 16 },
@@ -83351,7 +84067,8 @@ var require_lead_search = __commonJS({
83351
84067
  const wantsSiteHavers = bookingMode || auditMode;
83352
84068
  let saved = 0;
83353
84069
  let found = 0;
83354
- await (0, places_1.searchPlacesStream)({
84070
+ let alreadyInPipeline = 0;
84071
+ const stats = await (0, places_1.searchPlacesStream)({
83355
84072
  apiKey: args.placesKey,
83356
84073
  city: args.city,
83357
84074
  types: args.types,
@@ -83368,8 +84085,10 @@ var require_lead_search = __commonJS({
83368
84085
  continue;
83369
84086
  seen.add(r.place_id);
83370
84087
  const existing = db2.all("SELECT id FROM leads WHERE place_id = ?", [r.place_id]);
83371
- if (existing.length > 0)
84088
+ if (existing.length > 0) {
84089
+ alreadyInPipeline++;
83372
84090
  continue;
84091
+ }
83373
84092
  let leadKind = "no_website";
83374
84093
  let bookingStatus = null;
83375
84094
  let bookingPlatform = null;
@@ -83436,7 +84155,15 @@ var require_lead_search = __commonJS({
83436
84155
  });
83437
84156
  }
83438
84157
  });
83439
- return { found, saved };
84158
+ return {
84159
+ found,
84160
+ saved,
84161
+ scanned: stats.scanned,
84162
+ withWebsite: stats.withWebsite,
84163
+ outsideArea: stats.outsideArea,
84164
+ alreadyInPipeline,
84165
+ errors: [...new Set(stats.errors)]
84166
+ };
83440
84167
  }
83441
84168
  async function searchAndSaveLeads(args) {
83442
84169
  const db2 = (0, db_1.getDb)();
@@ -95688,8 +96415,8 @@ var require_email = __commonJS({
95688
96415
  var nodemailer_1 = __importDefault(require_nodemailer());
95689
96416
  function smtpFromSettings(get) {
95690
96417
  const host = get("smtp_host");
95691
- const user = get("smtp_user");
95692
- const pass = get("smtp_pass");
96418
+ const user = get("smtp_user") || get("imap_user");
96419
+ const pass = get("smtp_pass") || get("imap_pass");
95693
96420
  if (!host || !user || !pass)
95694
96421
  return null;
95695
96422
  const port = Number(get("smtp_port") || "587");
@@ -96239,6 +96966,7 @@ Respond with JSON only:
96239
96966
  AND COALESCE(followed_up_count, 0) <= 1
96240
96967
  AND email IS NOT NULL AND email != ''
96241
96968
  AND julianday('now') - julianday(last_contacted_at) >= ?
96969
+ ${args.onlyLeadIds ? `AND id IN (${args.onlyLeadIds.map(Number).join(",") || "NULL"})` : ""}
96242
96970
  ORDER BY last_contacted_at ASC
96243
96971
  LIMIT ?`, [args.afterDays, args.limit]);
96244
96972
  const agency2 = (0, agency_1.getAgencyProfile)().name ?? "";
@@ -96677,7 +97405,7 @@ var require_chat_tools = __commonJS({
96677
97405
  },
96678
97406
  {
96679
97407
  name: "provision_cms",
96680
- description: "Set up a CMS for a paying customer (lead in 'won' status). Creates Supabase records, redeploys with the dynamic CMS template, returns a one-time claim link.",
97408
+ description: "Give a built site its editor, keeping the design exactly as it is: the owner taps text and photos on their own page at <site>/edit (works on a phone) and has /admin for services, hours and photos. Returns the edit link and password to hand to the customer.",
96681
97409
  input_schema: {
96682
97410
  type: "object",
96683
97411
  properties: { id: { type: "number" } },
@@ -97473,8 +98201,15 @@ If you'd rather not hear from me again, just reply and let me know.`;
97473
98201
  }
97474
98202
  case "provision_cms": {
97475
98203
  const id = Number(input.id);
97476
- const result = await ctx.invokeIpc("cms:provision", id);
97477
- return result;
98204
+ const result = await ctx.invokeIpc("sites:addEditor", id);
98205
+ return {
98206
+ ok: true,
98207
+ id,
98208
+ publicUrl: result.url,
98209
+ editUrl: `${result.url}/edit`,
98210
+ adminUrl: result.admin?.url,
98211
+ adminPassword: result.admin?.password
98212
+ };
97478
98213
  }
97479
98214
  case "delete_lead": {
97480
98215
  const id = Number(input.id);
@@ -97731,6 +98466,7 @@ var require_auto_mode = __commonJS({
97731
98466
  exports2.resumeAutoJob = resumeAutoJob;
97732
98467
  exports2.cancelAutoJob = cancelAutoJob2;
97733
98468
  exports2.getAutoJobStatus = getAutoJobStatus;
98469
+ exports2.waitForAutoJob = waitForAutoJob;
97734
98470
  var db_1 = require_db();
97735
98471
  var builder_1 = require_builder();
97736
98472
  var AutoJob = class {
@@ -97942,6 +98678,20 @@ var require_auto_mode = __commonJS({
97942
98678
  function getAutoJobStatus() {
97943
98679
  return activeJob ? activeJob.snapshot() : null;
97944
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
+ }
97945
98695
  }
97946
98696
  });
97947
98697
 
@@ -98481,7 +99231,7 @@ var require_vercel_store = __commonJS({
98481
99231
  });
98482
99232
 
98483
99233
  // src/engine.ts
98484
- 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;
98485
99235
  var init_engine = __esm({
98486
99236
  "src/engine.ts"() {
98487
99237
  "use strict";
@@ -98510,6 +99260,7 @@ var init_engine = __esm({
98510
99260
  deployBookingOnly = builder.deployBookingOnly;
98511
99261
  deployStandaloneBooking = builder.deployStandaloneBooking;
98512
99262
  handoverSite = builder.handoverSite;
99263
+ addEditorToSite = builder.addEditorToSite;
98513
99264
  createBlobStore = vercelStore.createBlobStore;
98514
99265
  refreshModels = models.refreshModels;
98515
99266
  refreshIfStale = models.refreshIfStale;
@@ -98977,7 +99728,7 @@ var require_package2 = __commonJS({
98977
99728
  "package.json"(exports2, module2) {
98978
99729
  module2.exports = {
98979
99730
  name: "sproutkit-code",
98980
- version: "1.10.0",
99731
+ version: "1.11.0",
98981
99732
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
98982
99733
  bin: {
98983
99734
  sproutkit: "./dist/index.js"
@@ -99055,6 +99806,31 @@ function makeInvokeIpc(emit2) {
99055
99806
  emit: emit2
99056
99807
  });
99057
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
+ }
99058
99834
  if (channel === "cms:provision") {
99059
99835
  const leadId = Number(payload);
99060
99836
  const db2 = getDb();
@@ -100924,23 +101700,24 @@ async function cmdCms(argv) {
100924
101700
  return;
100925
101701
  }
100926
101702
  const status = json ? void 0 : new Status();
100927
- if (status) status.start(`Setting up CMS for lead #${id}`);
101703
+ if (status) status.start(`Adding the editor to lead #${id}'s site`);
100928
101704
  try {
100929
101705
  const result = await runTool("provision_cms", { id }, makeCtx({ json, status }));
100930
- if (status) status.done(true, result.publicUrl ? `CMS live at ${result.publicUrl}` : `CMS set up for #${id}`);
101706
+ if (status) status.done(true, result.publicUrl ? `Editor live at ${result.publicUrl}/edit` : `Editor added for #${id}`);
100931
101707
  autoSync();
100932
101708
  if (json) emitJson(result);
100933
- else sayAdminHandover(result.adminUrl, result.adminPassword);
101709
+ else sayAdminHandover(result.adminUrl, result.adminPassword, result.editUrl);
100934
101710
  } catch (e) {
100935
101711
  if (status) status.done(false, "CMS setup failed");
100936
101712
  json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
100937
101713
  process.exitCode = 1;
100938
101714
  }
100939
101715
  }
100940
- function sayAdminHandover(adminUrl, adminPassword) {
101716
+ function sayAdminHandover(adminUrl, adminPassword, editUrl) {
100941
101717
  if (!adminUrl || !adminPassword) return;
100942
101718
  say("");
100943
101719
  say(c.bold(" Client handover"));
101720
+ if (editUrl) say(` Edit page: ${editUrl} (tap text/photos to change them \u2014 works on a phone)`);
100944
101721
  say(` Admin panel: ${adminUrl}`);
100945
101722
  say(` Password: ${adminPassword}`);
100946
101723
  say(c.dim(" Send these to the client \u2014 they manage bookings and content there."));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutkit-code",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Sproutkit in your terminal — find leads, build sites, run outreach, all via chat.",
5
5
  "bin": {
6
6
  "sproutkit": "./dist/index.js"