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