@elitedcs/ghl-mcp 3.81.0 → 3.82.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/CHANGELOG.md +51 -0
- package/README.md +1 -1
- package/dist/index.js +2590 -416
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1006,10 +1006,10 @@ function parkedRefusal(current, next, parked) {
|
|
|
1006
1006
|
const currentById = new Map(current.filter(hasId).map((a) => [a.id, a]));
|
|
1007
1007
|
const nextById = new Map(next.filter(hasId).map((a) => [a.id, a]));
|
|
1008
1008
|
const contactsByStep = new Map(occupied.map((p) => [p.currentStepId, p.total]));
|
|
1009
|
-
const
|
|
1009
|
+
const guarded2 = protectedClosure([...contactsByStep.keys()], currentById);
|
|
1010
1010
|
const deleted = [];
|
|
1011
1011
|
const altered = [];
|
|
1012
|
-
for (const id of
|
|
1012
|
+
for (const id of guarded2) {
|
|
1013
1013
|
const before = currentById.get(id);
|
|
1014
1014
|
if (!before) continue;
|
|
1015
1015
|
const after = nextById.get(id);
|
|
@@ -1019,11 +1019,11 @@ function parkedRefusal(current, next, parked) {
|
|
|
1019
1019
|
}
|
|
1020
1020
|
if (stable(before) !== stable(after)) altered.push(id);
|
|
1021
1021
|
}
|
|
1022
|
-
for (const id of
|
|
1022
|
+
for (const id of guarded2) {
|
|
1023
1023
|
const head3 = currentById.get(id);
|
|
1024
1024
|
if (!head3) continue;
|
|
1025
1025
|
for (const target of branchTargetStepIds(head3)) {
|
|
1026
|
-
if (
|
|
1026
|
+
if (guarded2.has(target) || deleted.includes(target)) continue;
|
|
1027
1027
|
if (currentById.has(target) && !nextById.has(target)) deleted.push(target);
|
|
1028
1028
|
}
|
|
1029
1029
|
}
|
|
@@ -12921,7 +12921,8 @@ var init_schema = __esm({
|
|
|
12921
12921
|
}).strict(),
|
|
12922
12922
|
logo: import_zod42.z.object({
|
|
12923
12923
|
inlineSvg: import_zod42.z.string().optional(),
|
|
12924
|
-
|
|
12924
|
+
// A raster image carried inline (png/jpeg/gif/webp, base64): the brand page puts it straight into an <img src>.
|
|
12925
|
+
dataUri: import_zod42.z.string().regex(/^data:image\/(png|jpe?g|gif|webp);base64,[A-Za-z0-9+/=]+$/, "logo.dataUri must be a base64 png, jpeg, gif or webp data URI").optional(),
|
|
12925
12926
|
width: import_zod42.z.number().optional(),
|
|
12926
12927
|
height: import_zod42.z.number().optional(),
|
|
12927
12928
|
whiteFillFixed: import_zod42.z.boolean(),
|
|
@@ -13294,6 +13295,108 @@ var init_store = __esm({
|
|
|
13294
13295
|
}
|
|
13295
13296
|
});
|
|
13296
13297
|
|
|
13298
|
+
// src/command-os/design/svg-sanitize.ts
|
|
13299
|
+
function unquote(v) {
|
|
13300
|
+
return v.replace(/^"(.*)"$/s, "$1").replace(/^'(.*)'$/s, "$1");
|
|
13301
|
+
}
|
|
13302
|
+
function decoded(v) {
|
|
13303
|
+
let s = v;
|
|
13304
|
+
for (let i = 0; i < 5; i++) {
|
|
13305
|
+
const next = s.replace(/&#x([0-9a-f]{1,6});?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16))).replace(/&#(\d{1,7});?/g, (_m, n) => String.fromCodePoint(Math.min(parseInt(n, 10), 1114111))).replace(/&([a-z]+);/gi, (m, name) => NAMED_ENTITIES[name.toLowerCase()] ?? m);
|
|
13306
|
+
if (next === s) break;
|
|
13307
|
+
s = next;
|
|
13308
|
+
}
|
|
13309
|
+
s = s.replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16))).replace(/\\(.)/g, "$1");
|
|
13310
|
+
return s.replace(/[\u0000-\u0020\u007f-\u009f\u200b-\u200f\u2028\u2029\ufeff]/g, "");
|
|
13311
|
+
}
|
|
13312
|
+
function safeUrl(raw) {
|
|
13313
|
+
const s = decoded(unquote(raw)).toLowerCase();
|
|
13314
|
+
if (/[&\\<>]/.test(s)) return false;
|
|
13315
|
+
return s === "" || s.startsWith("#") || isRasterDataUri(s);
|
|
13316
|
+
}
|
|
13317
|
+
function safeStyle(v) {
|
|
13318
|
+
const s = decoded(v);
|
|
13319
|
+
if (/[&\\<>]/.test(s)) return false;
|
|
13320
|
+
return !/url\s*\(|@import|expression\s*\(|javascript:|vbscript:|behavior\s*:|-moz-binding|@charset|@namespace|image-set\s*\(|src\s*:/i.test(s);
|
|
13321
|
+
}
|
|
13322
|
+
function isRasterDataUri(v) {
|
|
13323
|
+
return typeof v === "string" && /^data:image\/(png|jpe?g|gif|webp);base64,[a-z0-9+/=]+$/i.test(v);
|
|
13324
|
+
}
|
|
13325
|
+
function sanitizeSvg(input) {
|
|
13326
|
+
if (typeof input !== "string") throw new Error(SVG_SHAPE_ERROR);
|
|
13327
|
+
let svg = input.replace(CDATA, "").replace(COMMENTS, "").replace(PROCESSING, "").replace(DOCTYPE, "").trim();
|
|
13328
|
+
if (!SHAPE.test(svg) || svg.length > SVG_MAX_BYTES) throw new Error(SVG_SHAPE_ERROR);
|
|
13329
|
+
let before = "";
|
|
13330
|
+
while (before !== svg) {
|
|
13331
|
+
before = svg;
|
|
13332
|
+
svg = svg.replace(DROP_ELEMENTS, "");
|
|
13333
|
+
}
|
|
13334
|
+
const classRules = /* @__PURE__ */ new Map();
|
|
13335
|
+
svg = svg.replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (_m, css2) => {
|
|
13336
|
+
for (const rule5 of css2.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
|
13337
|
+
const decls = rule5[2].trim().replace(/;\s*$/, "");
|
|
13338
|
+
if (!decls || !safeStyle(decls)) continue;
|
|
13339
|
+
for (const sel of rule5[1].split(",")) {
|
|
13340
|
+
const cm = /^\s*\.([A-Za-z_][\w-]*)\s*$/.exec(sel);
|
|
13341
|
+
if (cm) classRules.set(cm[1], classRules.has(cm[1]) ? `${classRules.get(cm[1])};${decls}` : decls);
|
|
13342
|
+
}
|
|
13343
|
+
}
|
|
13344
|
+
return "";
|
|
13345
|
+
});
|
|
13346
|
+
const attrValue = (v) => `"${v.replace(/"/g, """)}"`;
|
|
13347
|
+
svg = svg.replace(TAG, (_m, name, attrs, selfClose) => {
|
|
13348
|
+
let kept = [];
|
|
13349
|
+
let classes = [];
|
|
13350
|
+
let ownStyle = "";
|
|
13351
|
+
for (const a of attrs.matchAll(ATTR)) {
|
|
13352
|
+
const key = a[1];
|
|
13353
|
+
const k = key.toLowerCase();
|
|
13354
|
+
const hasValue = a[2] !== void 0;
|
|
13355
|
+
const val = a[2] ?? "";
|
|
13356
|
+
if (k.startsWith("on")) continue;
|
|
13357
|
+
if (URL_ATTRS.has(k) && !safeUrl(val)) continue;
|
|
13358
|
+
if (k === "style" && !safeStyle(unquote(val))) continue;
|
|
13359
|
+
if (/javascript:|data:text|vbscript:|data:image\/svg/i.test(decoded(unquote(val)))) continue;
|
|
13360
|
+
if (k === "class") classes = unquote(val).split(/\s+/).filter(Boolean);
|
|
13361
|
+
if (k === "style") ownStyle = unquote(val);
|
|
13362
|
+
kept.push(hasValue ? `${key}=${/^["']/.test(val) ? val : attrValue(val)}` : key);
|
|
13363
|
+
}
|
|
13364
|
+
const fromClasses = classes.flatMap((c) => classRules.has(c) ? [classRules.get(c)] : []);
|
|
13365
|
+
if (fromClasses.length) {
|
|
13366
|
+
kept = kept.filter((a) => !/^style=/i.test(a));
|
|
13367
|
+
kept.push(`style=${attrValue([...fromClasses, ownStyle].filter(Boolean).join(";"))}`);
|
|
13368
|
+
}
|
|
13369
|
+
return `<${name}${kept.length ? " " + kept.join(" ") : ""}${selfClose ? "/" : ""}>`;
|
|
13370
|
+
});
|
|
13371
|
+
if (/<style\b/i.test(svg) || !SHAPE.test(svg)) throw new Error(SVG_SHAPE_ERROR);
|
|
13372
|
+
return svg;
|
|
13373
|
+
}
|
|
13374
|
+
function sanitizeSvgOrDrop(input) {
|
|
13375
|
+
try {
|
|
13376
|
+
return sanitizeSvg(input);
|
|
13377
|
+
} catch {
|
|
13378
|
+
return void 0;
|
|
13379
|
+
}
|
|
13380
|
+
}
|
|
13381
|
+
var SVG_MAX_BYTES, SVG_SHAPE_ERROR, DROP_ELEMENTS, COMMENTS, PROCESSING, DOCTYPE, CDATA, TAG, ATTR, URL_ATTRS, NAMED_ENTITIES, SHAPE;
|
|
13382
|
+
var init_svg_sanitize = __esm({
|
|
13383
|
+
"src/command-os/design/svg-sanitize.ts"() {
|
|
13384
|
+
"use strict";
|
|
13385
|
+
SVG_MAX_BYTES = 6e4;
|
|
13386
|
+
SVG_SHAPE_ERROR = "The logo must be one <svg>\u2026</svg> under 60 KB.";
|
|
13387
|
+
DROP_ELEMENTS = /<\s*(script|foreignObject|iframe|object|embed|handler|set|animate|animateMotion|animateTransform|discard|audio|video)\b[^>]*(?:\/\s*>|>[\s\S]*?<\/\s*\1\s*>)/gi;
|
|
13388
|
+
COMMENTS = /<!--[\s\S]*?-->/g;
|
|
13389
|
+
PROCESSING = /<\?[\s\S]*?\?>/g;
|
|
13390
|
+
DOCTYPE = /<!DOCTYPE[\s\S]*?(?:\[[\s\S]*?\])?\s*>/gi;
|
|
13391
|
+
CDATA = /<!\[CDATA\[[\s\S]*?\]\]>/g;
|
|
13392
|
+
TAG = /<([a-zA-Z][\w:.-]*)((?:\s+[^\s=>/]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?)*)\s*(\/?)>/g;
|
|
13393
|
+
ATTR = /([^\s=/]+)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s>]+))?/g;
|
|
13394
|
+
URL_ATTRS = /* @__PURE__ */ new Set(["href", "xlink:href", "src", "from", "to", "values", "by", "formaction", "action"]);
|
|
13395
|
+
NAMED_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
|
13396
|
+
SHAPE = /^<svg[\s>][\s\S]*<\/svg>$/i;
|
|
13397
|
+
}
|
|
13398
|
+
});
|
|
13399
|
+
|
|
13297
13400
|
// src/command-os/design/extract.ts
|
|
13298
13401
|
async function fetchText(url, fetchImpl, timeoutMs = 15e3) {
|
|
13299
13402
|
const ctl = new AbortController();
|
|
@@ -13702,10 +13805,15 @@ function deriveBrandDna(raw, opts = {}) {
|
|
|
13702
13805
|
fixed = true;
|
|
13703
13806
|
}
|
|
13704
13807
|
const vb = svg.match(/viewBox=["']\s*[\d.-]+\s+[\d.-]+\s+([\d.]+)\s+([\d.]+)/i);
|
|
13705
|
-
|
|
13706
|
-
|
|
13707
|
-
|
|
13708
|
-
|
|
13808
|
+
const clean = sanitizeSvgOrDrop(svg);
|
|
13809
|
+
if (clean) {
|
|
13810
|
+
logoSvg = clean;
|
|
13811
|
+
logo = { inlineSvg: clean, whiteFillFixed: fixed, source: bl.where, rights: opts.rights ? "declared" : "inherits", ...vb && { width: parseFloat(vb[1]), height: parseFloat(vb[2]) } };
|
|
13812
|
+
declared++;
|
|
13813
|
+
evidence["logo"] = `${bl.where}${fixed ? "; white fills replaced with currentColor" : ""}${clean !== svg ? "; scripts, handlers and outside references stripped" : ""}`;
|
|
13814
|
+
} else {
|
|
13815
|
+
evidence["logo"] = `${bl.where}: not a plain drawing (or over 60 KB), so it was not kept`;
|
|
13816
|
+
}
|
|
13709
13817
|
} else if (raw.logo?.bytes && raw.logo.kind !== "svg") {
|
|
13710
13818
|
logoPng = raw.logo.bytes;
|
|
13711
13819
|
const mime = raw.logo.kind === "png" ? "image/png" : raw.logo.kind === "webp" ? "image/webp" : "image/jpeg";
|
|
@@ -13769,6 +13877,7 @@ var init_extract = __esm({
|
|
|
13769
13877
|
init_fonts();
|
|
13770
13878
|
init_schema();
|
|
13771
13879
|
init_store();
|
|
13880
|
+
init_svg_sanitize();
|
|
13772
13881
|
UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 GHLCommand-DesignStudio/0.1";
|
|
13773
13882
|
rank = (arr) => {
|
|
13774
13883
|
const m = /* @__PURE__ */ new Map();
|
|
@@ -16683,7 +16792,7 @@ function registerSetupTool(server2, pkgVersion) {
|
|
|
16683
16792
|
if (pkgVersion) setupPkgVersion = pkgVersion;
|
|
16684
16793
|
server2.tool(
|
|
16685
16794
|
"setup_ghl_mcp",
|
|
16686
|
-
"First-run setup AND license upgrades for GHL Command
|
|
16795
|
+
"First-run setup AND license upgrades for GHL Command. Validates your license and GHL credentials, then writes them to a per-user credentials file; restart Claude once after it completes. UPGRADING an existing install (e.g. free key to paid)? Supply ONLY email + license_key \u2014 the GHL credentials and any Workflow Builder unlock already saved on this machine are reused and kept; do not re-enter ghl_api_key or ghl_location_id. No license yet? Paid ($97/mo) at https://ghlcommand.com \u2014 or a FREE read-only key, instantly, at https://ghlcommand.com/free.",
|
|
16687
16796
|
{
|
|
16688
16797
|
email: import_zod48.z.string().email().describe("Email used at purchase."),
|
|
16689
16798
|
license_key: import_zod48.z.string().min(20).describe("License key from your purchase email."),
|
|
@@ -16987,7 +17096,7 @@ function registerFirebaseCaptureScriptTool(server2) {
|
|
|
16987
17096
|
"}",
|
|
16988
17097
|
"```",
|
|
16989
17098
|
"",
|
|
16990
|
-
"**Step 5.** Come back to Claude. Run `setup_ghl_mcp` (first-time install) or `enable_workflow_builder` (already set up) and paste the JSON into the `firebase_paste` field.
|
|
17099
|
+
"**Step 5.** Come back to Claude. Run `setup_ghl_mcp` (first-time install) or `enable_workflow_builder` (already set up) and paste the JSON into the `firebase_paste` field. GHL Command reads it automatically \u2014 you don't need to fill the individual `ghl_user_id`, `ghl_firebase_api_key`, `ghl_firebase_refresh_token` fields.",
|
|
16991
17100
|
"",
|
|
16992
17101
|
"Step-by-step screenshots: https://elitedcs.com/ghl-mcp-firebase"
|
|
16993
17102
|
].join("\n");
|
|
@@ -16998,7 +17107,7 @@ function registerFirebaseCaptureScriptTool(server2) {
|
|
|
16998
17107
|
function registerInteractiveCaptureTool(server2) {
|
|
16999
17108
|
server2.tool(
|
|
17000
17109
|
"capture_firebase_interactive",
|
|
17001
|
-
"One-click Workflow Builder unlock (EASIEST PATH \u2014 try this before auto_capture_firebase_script). Also the unlock for the FREE tier's account auditor (audit_workflows, validate_workflow) \u2014 same one-time login. Opens a Chrome window where you just log into GHL;
|
|
17110
|
+
"One-click Workflow Builder unlock (EASIEST PATH \u2014 try this before auto_capture_firebase_script). Also the unlock for the FREE tier's account auditor (audit_workflows, validate_workflow) \u2014 same one-time login. Opens a Chrome window where you just log into GHL; GHL Command captures the Firebase credentials itself and saves them. No DevTools, no pasting. Re-run the same tool if the first call says it's still waiting for your login. After the first successful run, future re-captures (refresh-token rotations) happen silently with no window at all. Falls back cleanly: if no Chrome/Edge is installed, use auto_capture_firebase_script instead.",
|
|
17002
17111
|
{},
|
|
17003
17112
|
async () => {
|
|
17004
17113
|
const existing = readCredentials();
|
|
@@ -17239,10 +17348,10 @@ function withSwitchLock(fn) {
|
|
|
17239
17348
|
}
|
|
17240
17349
|
function registerLocationSwitcherTools(server2, client, builderClient, registry2, mcpVersion) {
|
|
17241
17350
|
const versionLine = mcpVersion ? `
|
|
17242
|
-
|
|
17351
|
+
GHL Command version: v${mcpVersion}` : "";
|
|
17243
17352
|
server2.tool(
|
|
17244
17353
|
"get_current_location",
|
|
17245
|
-
"Show which GHL sub-account (location) is currently active, including API key status and installed
|
|
17354
|
+
"Show which GHL sub-account (location) is currently active, including API key status and the installed GHL Command version. All tools use this location by default unless you specify a different one.",
|
|
17246
17355
|
{},
|
|
17247
17356
|
async () => {
|
|
17248
17357
|
const locId = client.defaultLocationId || "NOT SET";
|
|
@@ -21314,11 +21423,11 @@ async function collectInstallChecks(installedVersion, client, builderClient, reg
|
|
|
21314
21423
|
]);
|
|
21315
21424
|
let versionCheck;
|
|
21316
21425
|
if (versionStatus.latest === null) {
|
|
21317
|
-
versionCheck = { name: "
|
|
21426
|
+
versionCheck = { name: "GHL Command version", status: "warn", detail: `You're on v${versionStatus.installed}. Could not reach npm to check for a newer version: ${versionStatus.fetchError ?? "unknown"}.` };
|
|
21318
21427
|
} else if (versionStatus.upToDate) {
|
|
21319
|
-
versionCheck = { name: "
|
|
21428
|
+
versionCheck = { name: "GHL Command version", status: "pass", detail: `Up to date at v${versionStatus.installed}.` };
|
|
21320
21429
|
} else {
|
|
21321
|
-
versionCheck = { name: "
|
|
21430
|
+
versionCheck = { name: "GHL Command version", status: "warn", detail: `You're on v${versionStatus.installed}; the latest is v${versionStatus.latest}. Quit Claude (Cmd+Q) and reopen to update.` };
|
|
21322
21431
|
}
|
|
21323
21432
|
const gatingCheck = (() => {
|
|
21324
21433
|
const r = getGatingReport?.();
|
|
@@ -21379,7 +21488,7 @@ function summarizeChecks(checks) {
|
|
|
21379
21488
|
function registerDiagnosticTools(server2, installedVersion, client, builderClient, registry2, getGatingReport, plan = "mcp") {
|
|
21380
21489
|
server2.tool(
|
|
21381
21490
|
"health_check",
|
|
21382
|
-
"Run a full health check of
|
|
21491
|
+
"Run a full health check of your GHL Command install. Reports on: version status, GHL API key validity, default location reachability, Firebase auth status (workflow builder), token registry, and the tool-gating policy (disabled tools/modules, account-admin opt-in, typos in gating env vars). Returns a structured pass/fail/warn list with detail per check. Use this when something feels broken or after credentials change. For a wider report that also probes every GHL capability live and shows what you are not yet using, run `run_checkup`.",
|
|
21383
21492
|
{},
|
|
21384
21493
|
async () => {
|
|
21385
21494
|
const checks = await collectInstallChecks(installedVersion, client, builderClient, registry2, getGatingReport, plan);
|
|
@@ -21870,7 +21979,7 @@ function safeParse(raw) {
|
|
|
21870
21979
|
return {};
|
|
21871
21980
|
}
|
|
21872
21981
|
}
|
|
21873
|
-
function
|
|
21982
|
+
function safeUrl2(x, max = MAX_URL) {
|
|
21874
21983
|
const raw = str2(x, max);
|
|
21875
21984
|
if (!raw) return void 0;
|
|
21876
21985
|
let u;
|
|
@@ -21898,8 +22007,8 @@ function sanitizeProfile(raw) {
|
|
|
21898
22007
|
agencyName: str2(r.agencyName, 80),
|
|
21899
22008
|
legalName: str2(r.legalName),
|
|
21900
22009
|
accent: normalizeAccent(r.accent),
|
|
21901
|
-
logoUrl:
|
|
21902
|
-
website:
|
|
22010
|
+
logoUrl: safeUrl2(r.logoUrl),
|
|
22011
|
+
website: safeUrl2(r.website),
|
|
21903
22012
|
sender: drop({
|
|
21904
22013
|
fromName: str2(sender?.fromName, 80),
|
|
21905
22014
|
replyTo: str2(sender?.replyTo),
|
|
@@ -21940,9 +22049,44 @@ function sanitizeProfile(raw) {
|
|
|
21940
22049
|
timezone: str2(defaults?.timezone, 64),
|
|
21941
22050
|
industry: str2(defaults?.industry, 64)
|
|
21942
22051
|
}),
|
|
22052
|
+
agencyOs: sanitizeAgencyOs(r.agencyOs),
|
|
21943
22053
|
updated_at: str2(r.updated_at, 40)
|
|
21944
22054
|
};
|
|
21945
22055
|
}
|
|
22056
|
+
function sanitizeAgencyOs(raw) {
|
|
22057
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
22058
|
+
const r = raw;
|
|
22059
|
+
const ica = r.ica ?? {};
|
|
22060
|
+
const offer = r.offer ?? {};
|
|
22061
|
+
const lines = (x) => list(x, (s) => str2(s, MAX_STR * 2));
|
|
22062
|
+
const out = drop({
|
|
22063
|
+
story: str2(r.story, MAX_PARA),
|
|
22064
|
+
method: str2(r.method, MAX_PARA),
|
|
22065
|
+
voice: str2(r.voice, 400),
|
|
22066
|
+
values: lines(r.values),
|
|
22067
|
+
goals: str2(r.goals, 400),
|
|
22068
|
+
proof: lines(r.proof),
|
|
22069
|
+
ica: drop({
|
|
22070
|
+
who: str2(ica.who, 400),
|
|
22071
|
+
pains: lines(ica.pains),
|
|
22072
|
+
desires: lines(ica.desires),
|
|
22073
|
+
objections: lines(ica.objections),
|
|
22074
|
+
triggers: lines(ica.triggers),
|
|
22075
|
+
vocabulary: lines(ica.vocabulary),
|
|
22076
|
+
hangouts: lines(ica.hangouts),
|
|
22077
|
+
villains: lines(ica.villains)
|
|
22078
|
+
}),
|
|
22079
|
+
offer: drop({
|
|
22080
|
+
name: str2(offer.name, 120),
|
|
22081
|
+
promise: str2(offer.promise, 400),
|
|
22082
|
+
valueStack: lines(offer.valueStack),
|
|
22083
|
+
price: str2(offer.price, 120),
|
|
22084
|
+
guarantee: str2(offer.guarantee, 600),
|
|
22085
|
+
accelerators: lines(offer.accelerators)
|
|
22086
|
+
})
|
|
22087
|
+
});
|
|
22088
|
+
return out && Object.keys(out).length ? out : void 0;
|
|
22089
|
+
}
|
|
21946
22090
|
function legacyBranding(profileFile) {
|
|
21947
22091
|
try {
|
|
21948
22092
|
const raw = JSON.parse(fs12.readFileSync(path11.join(path11.dirname(profileFile), "cockpit-state.json"), "utf8"));
|
|
@@ -21960,6 +22104,23 @@ function readAgencyProfile(file = agencyProfilePath()) {
|
|
|
21960
22104
|
const legacy = legacyBranding(file);
|
|
21961
22105
|
return legacy.agencyName || legacy.accent ? sanitizeProfile({ ...EMPTY, ...legacy }) : { ...EMPTY };
|
|
21962
22106
|
}
|
|
22107
|
+
function mergeNested(base, patch, depth) {
|
|
22108
|
+
const out = { ...base };
|
|
22109
|
+
for (const [ck, cv] of Object.entries(patch)) {
|
|
22110
|
+
if (ck === "__proto__" || ck === "constructor" || ck === "prototype") continue;
|
|
22111
|
+
if (cv === void 0) continue;
|
|
22112
|
+
if (cv === null) {
|
|
22113
|
+
delete out[ck];
|
|
22114
|
+
continue;
|
|
22115
|
+
}
|
|
22116
|
+
if (depth > 1 && isPlainObject(cv) && isPlainObject(out[ck])) {
|
|
22117
|
+
out[ck] = mergeNested(out[ck], cv, depth - 1);
|
|
22118
|
+
continue;
|
|
22119
|
+
}
|
|
22120
|
+
out[ck] = cv;
|
|
22121
|
+
}
|
|
22122
|
+
return out;
|
|
22123
|
+
}
|
|
21963
22124
|
function mergeAgencyProfile(existing, patch) {
|
|
21964
22125
|
const p = isPlainObject(patch) ? patch : {};
|
|
21965
22126
|
const merged = Object.assign(/* @__PURE__ */ Object.create(null), existing);
|
|
@@ -21972,17 +22133,7 @@ function mergeAgencyProfile(existing, patch) {
|
|
|
21972
22133
|
continue;
|
|
21973
22134
|
}
|
|
21974
22135
|
if (NESTED.has(k) && isPlainObject(v)) {
|
|
21975
|
-
|
|
21976
|
-
for (const [ck, cv] of Object.entries(v)) {
|
|
21977
|
-
if (ck === "__proto__" || ck === "constructor" || ck === "prototype") continue;
|
|
21978
|
-
if (cv === void 0) continue;
|
|
21979
|
-
if (cv === null) {
|
|
21980
|
-
delete base[ck];
|
|
21981
|
-
continue;
|
|
21982
|
-
}
|
|
21983
|
-
base[ck] = cv;
|
|
21984
|
-
}
|
|
21985
|
-
merged[k] = base;
|
|
22136
|
+
merged[k] = mergeNested(isPlainObject(merged[k]) ? merged[k] : {}, v, 2);
|
|
21986
22137
|
continue;
|
|
21987
22138
|
}
|
|
21988
22139
|
merged[k] = v;
|
|
@@ -22043,7 +22194,7 @@ function writeAgencyProfile(patch, file = agencyProfilePath()) {
|
|
|
22043
22194
|
function profileIsUsable(p) {
|
|
22044
22195
|
return Boolean(p.agencyName);
|
|
22045
22196
|
}
|
|
22046
|
-
var fs12, path11, AGENCY_PROFILE_ENV, EMPTY, MAX_URL, MAX_SOP, PATCHABLE, NESTED, isPlainObject;
|
|
22197
|
+
var fs12, path11, AGENCY_PROFILE_ENV, EMPTY, MAX_URL, MAX_SOP, MAX_PARA, PATCHABLE, NESTED, isPlainObject;
|
|
22047
22198
|
var init_agency_profile = __esm({
|
|
22048
22199
|
"src/agency-profile.ts"() {
|
|
22049
22200
|
"use strict";
|
|
@@ -22055,6 +22206,7 @@ var init_agency_profile = __esm({
|
|
|
22055
22206
|
EMPTY = { v: 1 };
|
|
22056
22207
|
MAX_URL = 500;
|
|
22057
22208
|
MAX_SOP = 2e3;
|
|
22209
|
+
MAX_PARA = 2e3;
|
|
22058
22210
|
PATCHABLE = [
|
|
22059
22211
|
"agencyName",
|
|
22060
22212
|
"legalName",
|
|
@@ -22066,9 +22218,10 @@ var init_agency_profile = __esm({
|
|
|
22066
22218
|
"pricing",
|
|
22067
22219
|
"delivery",
|
|
22068
22220
|
"staff",
|
|
22069
|
-
"defaults"
|
|
22221
|
+
"defaults",
|
|
22222
|
+
"agencyOs"
|
|
22070
22223
|
];
|
|
22071
|
-
NESTED = /* @__PURE__ */ new Set(["sender", "pricing", "delivery", "defaults"]);
|
|
22224
|
+
NESTED = /* @__PURE__ */ new Set(["sender", "pricing", "delivery", "defaults", "agencyOs"]);
|
|
22072
22225
|
isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
22073
22226
|
}
|
|
22074
22227
|
});
|
|
@@ -22262,7 +22415,7 @@ function parseNote(body) {
|
|
|
22262
22415
|
if (!s || typeof s !== "object" || typeof s.biz !== "string") return null;
|
|
22263
22416
|
if (!s.ans || typeof s.ans !== "object") return null;
|
|
22264
22417
|
return {
|
|
22265
|
-
state: { biz: s.biz, trade: s.trade, ans: s.ans, skip: s.skip ?? {}, asm: s.asm ?? {} },
|
|
22418
|
+
state: { biz: s.biz, trade: s.trade, ans: s.ans, skip: s.skip ?? {}, asm: s.asm ?? {}, ...s.client && typeof s.client === "object" ? { client: s.client } : {} },
|
|
22266
22419
|
computed: p.computed ?? null
|
|
22267
22420
|
};
|
|
22268
22421
|
}
|
|
@@ -22820,7 +22973,12 @@ function renderReport(opts) {
|
|
|
22820
22973
|
out.push("<header>");
|
|
22821
22974
|
if (agency.name) out.push('<p class="by">Prepared by ' + esc(agency.name) + "</p>");
|
|
22822
22975
|
out.push("<h1>Where " + biz + " is leaking</h1>");
|
|
22976
|
+
var who = state.client && state.client.name ? esc(state.client.name) + (state.client.role ? ", " + esc(state.client.role) : "") : "";
|
|
22977
|
+
if (who) out.push('<p class="for">Prepared for ' + who + "</p>");
|
|
22823
22978
|
if (date) out.push('<p class="when">' + esc(date) + "</p>");
|
|
22979
|
+
if (agency.method || agency.promise) {
|
|
22980
|
+
out.push('<p class="about">' + [agency.method, agency.promise].filter(Boolean).map(esc).join(" ") + "</p>");
|
|
22981
|
+
}
|
|
22824
22982
|
out.push("</header>");
|
|
22825
22983
|
if (r.verdict === "insufficient") {
|
|
22826
22984
|
out.push('<div class="verdict stop"><b>We could not put a number on this yet</b>' + esc(r.line || "") + "</div>");
|
|
@@ -22939,7 +23097,7 @@ function sourceLine(state, r, labels) {
|
|
|
22939
23097
|
}
|
|
22940
23098
|
function footer(agency) {
|
|
22941
23099
|
if (!agency.name) return "";
|
|
22942
|
-
return "<footer>" + esc(agency.name) + (agency.contact ? " · " + esc(agency.contact) : "") + "</footer>";
|
|
23100
|
+
return "<footer>" + esc(agency.name) + (agency.contact ? " · " + esc(agency.contact) : "") + (agency.guarantee ? '<br><span class="guarantee">' + esc(agency.guarantee) + "</span>" : "") + "</footer>";
|
|
22943
23101
|
}
|
|
22944
23102
|
function css(accent) {
|
|
22945
23103
|
return [
|
|
@@ -22951,6 +23109,9 @@ function css(accent) {
|
|
|
22951
23109
|
".by{font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--a);margin:0 0 .4rem;font-weight:600}",
|
|
22952
23110
|
"h1{font-size:1.9rem;line-height:1.15;margin:0;font-weight:700}",
|
|
22953
23111
|
".when{color:var(--mut);font-size:.82rem;margin:.4rem 0 0}",
|
|
23112
|
+
".for{color:var(--ink);font-size:.95rem;margin:.5rem 0 0}",
|
|
23113
|
+
".about{color:var(--mut);font-size:.95rem;margin:.6rem 0 0;max-width:38rem}",
|
|
23114
|
+
".guarantee{color:var(--mut);font-size:.85rem}",
|
|
22954
23115
|
"h2{font-size:1.15rem;margin:2rem 0 .5rem;font-weight:700}",
|
|
22955
23116
|
"table{border-collapse:collapse;width:100%;margin:.6rem 0;font-size:.94rem}",
|
|
22956
23117
|
"th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--rule);vertical-align:top}",
|
|
@@ -22988,7 +23149,7 @@ function today() {
|
|
|
22988
23149
|
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
22989
23150
|
}
|
|
22990
23151
|
function normalise(s) {
|
|
22991
|
-
return { biz: s.biz, trade: s.trade, ans: s.ans, skip: s.skip ?? {}, asm: s.asm ?? {} };
|
|
23152
|
+
return { biz: s.biz, trade: s.trade, ans: s.ans, skip: s.skip ?? {}, asm: s.asm ?? {}, ...s.client ? { client: s.client } : {} };
|
|
22992
23153
|
}
|
|
22993
23154
|
function computedFor(state) {
|
|
22994
23155
|
const r = compute(state.ans, state.skip, state.asm);
|
|
@@ -23004,9 +23165,11 @@ function computedFor(state) {
|
|
|
23004
23165
|
async function createPipeline(client, locationId2) {
|
|
23005
23166
|
try {
|
|
23006
23167
|
await client.post("/opportunities/pipelines", {
|
|
23007
|
-
|
|
23008
|
-
|
|
23009
|
-
|
|
23168
|
+
body: {
|
|
23169
|
+
locationId: locationId2,
|
|
23170
|
+
name: PIPELINE_NAME,
|
|
23171
|
+
stages: STAGES.map((name, position2) => ({ name, position: position2, showInFunnel: true, showInPieChart: true }))
|
|
23172
|
+
}
|
|
23010
23173
|
});
|
|
23011
23174
|
return { ok: true };
|
|
23012
23175
|
} catch (e) {
|
|
@@ -23045,6 +23208,87 @@ async function findStage(client, locationId2, stage, mayCreate = false) {
|
|
|
23045
23208
|
}
|
|
23046
23209
|
return { pipelineId: pipe.id, stageId: st.id, stageName: stage };
|
|
23047
23210
|
}
|
|
23211
|
+
async function saveAssessment(client, locationId2, args) {
|
|
23212
|
+
const state = normalise(args.assessment);
|
|
23213
|
+
const computed = computedFor(state);
|
|
23214
|
+
const stage = stageFor({ verdict: computed.verdict, proposalSent: args.proposalSent, outcome: args.outcome });
|
|
23215
|
+
const { payload: contactBody, dedupable } = buildContact(state, stage, {
|
|
23216
|
+
name: args.contactName,
|
|
23217
|
+
email: args.email,
|
|
23218
|
+
phone: args.phone
|
|
23219
|
+
});
|
|
23220
|
+
const upserted = await client.post("/contacts/upsert", {
|
|
23221
|
+
body: { ...contactBody, locationId: locationId2 }
|
|
23222
|
+
});
|
|
23223
|
+
const contactId = upserted?.contact?.id ?? upserted?.id;
|
|
23224
|
+
if (!contactId) {
|
|
23225
|
+
return { saved: false, error: "GoHighLevel did not return a contact id, so nothing was written. Nothing partial was left behind." };
|
|
23226
|
+
}
|
|
23227
|
+
const bodies = buildNoteBodies(buildNote(state, computed, LOOKUP, today()));
|
|
23228
|
+
const noteIds = [];
|
|
23229
|
+
for (const body of bodies) {
|
|
23230
|
+
const note = await client.post(`/contacts/${contactId}/notes`, { body: { body } });
|
|
23231
|
+
const id = note?.note?.id ?? note?.id;
|
|
23232
|
+
if (id) noteIds.push(id);
|
|
23233
|
+
}
|
|
23234
|
+
if (noteIds.length !== bodies.length) {
|
|
23235
|
+
return {
|
|
23236
|
+
saved: false,
|
|
23237
|
+
contactId,
|
|
23238
|
+
error: `the answer sheet needed ${bodies.length} note(s) and only ${noteIds.length} were written. A part-written record will NOT load \u2014 it refuses rather than returning half an assessment. Save again.`
|
|
23239
|
+
};
|
|
23240
|
+
}
|
|
23241
|
+
const hit = await findStage(client, locationId2, stage, args.createPipeline === true);
|
|
23242
|
+
const opp = buildOpportunity(state, computed, stage, args.proposalValue);
|
|
23243
|
+
let opportunity = null;
|
|
23244
|
+
let opportunityNote = null;
|
|
23245
|
+
if ("error" in hit) {
|
|
23246
|
+
opportunityNote = hit.error;
|
|
23247
|
+
} else {
|
|
23248
|
+
const body = {
|
|
23249
|
+
locationId: locationId2,
|
|
23250
|
+
contactId,
|
|
23251
|
+
name: opp.name,
|
|
23252
|
+
status: opp.status,
|
|
23253
|
+
monetaryValue: opp.monetaryValue,
|
|
23254
|
+
pipelineId: hit.pipelineId,
|
|
23255
|
+
pipelineStageId: hit.stageId,
|
|
23256
|
+
source: "Command OS assessment"
|
|
23257
|
+
};
|
|
23258
|
+
try {
|
|
23259
|
+
opportunity = await client.post("/opportunities/", { body });
|
|
23260
|
+
} catch (e) {
|
|
23261
|
+
const raw = String(e?.message ?? e);
|
|
23262
|
+
const existing = /OPPORTUNITY_NO_DUPLICATE/.test(raw) ? raw.match(/"existingId"\s*:\s*"([^"]+)"/)?.[1] : void 0;
|
|
23263
|
+
if (!existing) throw e;
|
|
23264
|
+
opportunity = await client.put(`/opportunities/${existing}`, {
|
|
23265
|
+
body: {
|
|
23266
|
+
name: opp.name,
|
|
23267
|
+
status: opp.status,
|
|
23268
|
+
monetaryValue: opp.monetaryValue,
|
|
23269
|
+
pipelineId: hit.pipelineId,
|
|
23270
|
+
pipelineStageId: hit.stageId
|
|
23271
|
+
}
|
|
23272
|
+
});
|
|
23273
|
+
opportunityNote = `This business already had a deal on your Assessments board, so it was moved to "${stage}" and its value updated rather than a second one being created.`;
|
|
23274
|
+
}
|
|
23275
|
+
}
|
|
23276
|
+
return {
|
|
23277
|
+
saved: true,
|
|
23278
|
+
contactId,
|
|
23279
|
+
noteIds,
|
|
23280
|
+
notesWritten: bodies.length,
|
|
23281
|
+
stage,
|
|
23282
|
+
tag: tagFor(stage),
|
|
23283
|
+
verdict: computed.verdict,
|
|
23284
|
+
monthly: Math.round(computed.monthly),
|
|
23285
|
+
hoursPerWeek: Number(computed.hrsWk.toFixed(1)),
|
|
23286
|
+
opportunity,
|
|
23287
|
+
opportunityNote,
|
|
23288
|
+
warning: dedupable ? null : "No email or phone was given, so this contact cannot be matched next time and a second assessment for the same business would create a second contact.",
|
|
23289
|
+
whereItLives: `Contact ${contactId} in location ${locationId2}. Anyone on your team with a GoHighLevel login can open it.`
|
|
23290
|
+
};
|
|
23291
|
+
}
|
|
23048
23292
|
function registerAssessmentTools(server2, client) {
|
|
23049
23293
|
safeTool(
|
|
23050
23294
|
server2,
|
|
@@ -23061,88 +23305,7 @@ function registerAssessmentTools(server2, client) {
|
|
|
23061
23305
|
proposalSent: import_zod62.z.boolean().optional(),
|
|
23062
23306
|
createPipeline: import_zod62.z.boolean().optional().describe('Create the "Assessments" pipeline in your own GoHighLevel if it is not there yet. Off by default: it is a write to your account, so it is asked for rather than assumed. Say yes the first time and never again.')
|
|
23063
23307
|
},
|
|
23064
|
-
async (args) =>
|
|
23065
|
-
const locationId2 = client.resolveLocationId(args.locationId);
|
|
23066
|
-
const state = normalise(args.assessment);
|
|
23067
|
-
const computed = computedFor(state);
|
|
23068
|
-
const stage = stageFor({ verdict: computed.verdict, proposalSent: args.proposalSent, outcome: args.outcome });
|
|
23069
|
-
const { payload: contactBody, dedupable } = buildContact(state, stage, {
|
|
23070
|
-
name: args.contactName,
|
|
23071
|
-
email: args.email,
|
|
23072
|
-
phone: args.phone
|
|
23073
|
-
});
|
|
23074
|
-
const upserted = await client.post("/contacts/upsert", {
|
|
23075
|
-
body: { ...contactBody, locationId: locationId2 }
|
|
23076
|
-
});
|
|
23077
|
-
const contactId = upserted?.contact?.id ?? upserted?.id;
|
|
23078
|
-
if (!contactId) {
|
|
23079
|
-
return { saved: false, error: "GoHighLevel did not return a contact id, so nothing was written. Nothing partial was left behind." };
|
|
23080
|
-
}
|
|
23081
|
-
const bodies = buildNoteBodies(buildNote(state, computed, LOOKUP, today()));
|
|
23082
|
-
const noteIds = [];
|
|
23083
|
-
for (const body of bodies) {
|
|
23084
|
-
const note = await client.post(`/contacts/${contactId}/notes`, { body: { body } });
|
|
23085
|
-
const id = note?.note?.id ?? note?.id;
|
|
23086
|
-
if (id) noteIds.push(id);
|
|
23087
|
-
}
|
|
23088
|
-
if (noteIds.length !== bodies.length) {
|
|
23089
|
-
return {
|
|
23090
|
-
saved: false,
|
|
23091
|
-
contactId,
|
|
23092
|
-
error: `the answer sheet needed ${bodies.length} note(s) and only ${noteIds.length} were written. A part-written record will NOT load \u2014 it refuses rather than returning half an assessment. Save again.`
|
|
23093
|
-
};
|
|
23094
|
-
}
|
|
23095
|
-
const hit = await findStage(client, locationId2, stage, args.createPipeline === true);
|
|
23096
|
-
const opp = buildOpportunity(state, computed, stage, args.proposalValue);
|
|
23097
|
-
let opportunity = null;
|
|
23098
|
-
let opportunityNote = null;
|
|
23099
|
-
if ("error" in hit) {
|
|
23100
|
-
opportunityNote = hit.error;
|
|
23101
|
-
} else {
|
|
23102
|
-
const body = {
|
|
23103
|
-
locationId: locationId2,
|
|
23104
|
-
contactId,
|
|
23105
|
-
name: opp.name,
|
|
23106
|
-
status: opp.status,
|
|
23107
|
-
monetaryValue: opp.monetaryValue,
|
|
23108
|
-
pipelineId: hit.pipelineId,
|
|
23109
|
-
pipelineStageId: hit.stageId,
|
|
23110
|
-
source: "Command OS assessment"
|
|
23111
|
-
};
|
|
23112
|
-
try {
|
|
23113
|
-
opportunity = await client.post("/opportunities/", { body });
|
|
23114
|
-
} catch (e) {
|
|
23115
|
-
const raw = String(e?.message ?? e);
|
|
23116
|
-
const existing = /OPPORTUNITY_NO_DUPLICATE/.test(raw) ? raw.match(/"existingId"\s*:\s*"([^"]+)"/)?.[1] : void 0;
|
|
23117
|
-
if (!existing) throw e;
|
|
23118
|
-
opportunity = await client.put(`/opportunities/${existing}`, {
|
|
23119
|
-
body: {
|
|
23120
|
-
name: opp.name,
|
|
23121
|
-
status: opp.status,
|
|
23122
|
-
monetaryValue: opp.monetaryValue,
|
|
23123
|
-
pipelineId: hit.pipelineId,
|
|
23124
|
-
pipelineStageId: hit.stageId
|
|
23125
|
-
}
|
|
23126
|
-
});
|
|
23127
|
-
opportunityNote = `This business already had a deal on your Assessments board, so it was moved to "${stage}" and its value updated rather than a second one being created.`;
|
|
23128
|
-
}
|
|
23129
|
-
}
|
|
23130
|
-
return {
|
|
23131
|
-
saved: true,
|
|
23132
|
-
contactId,
|
|
23133
|
-
noteIds,
|
|
23134
|
-
notesWritten: bodies.length,
|
|
23135
|
-
stage,
|
|
23136
|
-
tag: tagFor(stage),
|
|
23137
|
-
verdict: computed.verdict,
|
|
23138
|
-
monthly: Math.round(computed.monthly),
|
|
23139
|
-
hoursPerWeek: Number(computed.hrsWk.toFixed(1)),
|
|
23140
|
-
opportunity,
|
|
23141
|
-
opportunityNote,
|
|
23142
|
-
warning: dedupable ? null : "No email or phone was given, so this contact cannot be matched next time and a second assessment for the same business would create a second contact.",
|
|
23143
|
-
whereItLives: `Contact ${contactId} in location ${locationId2}. Anyone on your team with a GoHighLevel login can open it.`
|
|
23144
|
-
};
|
|
23145
|
-
}
|
|
23308
|
+
async (args) => saveAssessment(client, client.resolveLocationId(args.locationId), args)
|
|
23146
23309
|
);
|
|
23147
23310
|
safeTool(
|
|
23148
23311
|
server2,
|
|
@@ -23340,7 +23503,8 @@ var init_assessment = __esm({
|
|
|
23340
23503
|
trade: import_zod62.z.string().optional().describe("Their trade. Decides which benchmarks may be applied."),
|
|
23341
23504
|
ans: import_zod62.z.record(import_zod62.z.union([import_zod62.z.string(), import_zod62.z.number()])).describe("questionId -> answer, exactly as typed."),
|
|
23342
23505
|
skip: import_zod62.z.record(import_zod62.z.boolean()).optional().describe("questionId -> true where they did not know."),
|
|
23343
|
-
asm: import_zod62.z.record(import_zod62.z.number()).optional().describe("assumption id -> rate as a decimal.")
|
|
23506
|
+
asm: import_zod62.z.record(import_zod62.z.number()).optional().describe("assumption id -> rate as a decimal."),
|
|
23507
|
+
client: import_zod62.z.object({ name: import_zod62.z.string().max(120), role: import_zod62.z.string().max(80), email: import_zod62.z.string().max(200), phone: import_zod62.z.string().max(40), website: import_zod62.z.string().max(300), city: import_zod62.z.string().max(120) }).partial().optional().describe("The person and place this is for, as typed on The client step. Rides with the answers so a load brings it back.")
|
|
23344
23508
|
});
|
|
23345
23509
|
}
|
|
23346
23510
|
});
|
|
@@ -24315,9 +24479,9 @@ function scanTags(html2) {
|
|
|
24315
24479
|
if (!m[1]) {
|
|
24316
24480
|
const raw = m[3];
|
|
24317
24481
|
let a;
|
|
24318
|
-
|
|
24319
|
-
while (a =
|
|
24320
|
-
if (a.index ===
|
|
24482
|
+
ATTR2.lastIndex = 0;
|
|
24483
|
+
while (a = ATTR2.exec(raw)) {
|
|
24484
|
+
if (a.index === ATTR2.lastIndex) ATTR2.lastIndex++;
|
|
24321
24485
|
const k = a[1].toLowerCase();
|
|
24322
24486
|
if (k === "/") continue;
|
|
24323
24487
|
attrs[k] = (a[2] ?? a[3] ?? a[4] ?? "").trim();
|
|
@@ -24420,13 +24584,13 @@ function lintDesignContract(html2, medium) {
|
|
|
24420
24584
|
}
|
|
24421
24585
|
};
|
|
24422
24586
|
}
|
|
24423
|
-
var SLIDE_TYPES, ROLES,
|
|
24587
|
+
var SLIDE_TYPES, ROLES, ATTR2;
|
|
24424
24588
|
var init_lint = __esm({
|
|
24425
24589
|
"src/command-os/design/lint.ts"() {
|
|
24426
24590
|
"use strict";
|
|
24427
24591
|
SLIDE_TYPES = ["title", "section", "content", "quote", "closing"];
|
|
24428
24592
|
ROLES = ["headline", "body", "caption", "logo", "cta-primary", "cta-secondary", "nav", "eyebrow"];
|
|
24429
|
-
|
|
24593
|
+
ATTR2 = /([\w:.-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
24430
24594
|
}
|
|
24431
24595
|
});
|
|
24432
24596
|
|
|
@@ -24556,8 +24720,9 @@ function composeBrief(d, brief) {
|
|
|
24556
24720
|
];
|
|
24557
24721
|
const chosenContract = conversionMode ? conversionContract : contract;
|
|
24558
24722
|
const checklist = design.map((r) => `${r.id} ${r.name} \u2014 ${r.threshold}`);
|
|
24559
|
-
const
|
|
24560
|
-
|
|
24723
|
+
const cleanLogo = d.logo?.inlineSvg ? sanitizeSvgOrDrop(d.logo.inlineSvg) : void 0;
|
|
24724
|
+
const logoBlock = cleanLogo ? `Logo markup (use verbatim, colour via currentColor):
|
|
24725
|
+
${cleanLogo.slice(0, 6e3)}` : d.logo?.dataUri ? `Logo markup: <img src="${d.logo.dataUri.slice(0, 40)}\u2026" alt="${d.name}" data-role="logo" data-media="semantic"> (the full data URI is in the brand file)` : 'No logo is stored for this brand; use the brand name set in the display face as a wordmark, data-role="logo".';
|
|
24561
24726
|
const markdown = [
|
|
24562
24727
|
`# Design brief \u2014 ${brief.medium} for ${d.name}`,
|
|
24563
24728
|
"",
|
|
@@ -24610,6 +24775,7 @@ var init_brief = __esm({
|
|
|
24610
24775
|
init_color();
|
|
24611
24776
|
init_fonts();
|
|
24612
24777
|
init_lint();
|
|
24778
|
+
init_svg_sanitize();
|
|
24613
24779
|
}
|
|
24614
24780
|
});
|
|
24615
24781
|
|
|
@@ -26562,7 +26728,7 @@ function readAgencyBranding(base = appDataDir()) {
|
|
|
26562
26728
|
const out = {};
|
|
26563
26729
|
if (typeof st.agencyName === "string" && st.agencyName.trim()) out.agencyName = st.agencyName.trim();
|
|
26564
26730
|
if (isHex(st.agencyAccent)) out.accent = st.agencyAccent;
|
|
26565
|
-
if (
|
|
26731
|
+
if (isRasterDataUri(st.agencyLogoDataUri)) out.logoDataUri = st.agencyLogoDataUri;
|
|
26566
26732
|
return out;
|
|
26567
26733
|
} catch {
|
|
26568
26734
|
return {};
|
|
@@ -26572,7 +26738,7 @@ function resolveBranding(overrides = {}, base) {
|
|
|
26572
26738
|
const saved = readAgencyBranding(base);
|
|
26573
26739
|
const agencyName = overrides.agencyName?.trim() || saved.agencyName;
|
|
26574
26740
|
const accent = isHex(overrides.accent) ? overrides.accent : saved.accent ?? DEFAULT_ACCENT;
|
|
26575
|
-
const logoDataUri = overrides.logoDataUri
|
|
26741
|
+
const logoDataUri = isRasterDataUri(overrides.logoDataUri) ? overrides.logoDataUri : saved.logoDataUri;
|
|
26576
26742
|
return { ...agencyName && { agencyName }, accent, ...logoDataUri && { logoDataUri } };
|
|
26577
26743
|
}
|
|
26578
26744
|
var import_node_fs2, import_node_path2, DEFAULT_ACCENT;
|
|
@@ -26582,6 +26748,7 @@ var init_branding = __esm({
|
|
|
26582
26748
|
import_node_fs2 = require("node:fs");
|
|
26583
26749
|
import_node_path2 = require("node:path");
|
|
26584
26750
|
init_credentials_store();
|
|
26751
|
+
init_svg_sanitize();
|
|
26585
26752
|
DEFAULT_ACCENT = "#B8934A";
|
|
26586
26753
|
}
|
|
26587
26754
|
});
|
|
@@ -26631,7 +26798,7 @@ td.ev{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;color:var(--mute
|
|
|
26631
26798
|
.shots a{font-size:13px;color:var(--accent)}
|
|
26632
26799
|
footer{margin-top:40px;color:var(--muted);font-size:13px;border-top:1px solid var(--line);padding-top:12px}
|
|
26633
26800
|
</style></head><body><div class="wrap">
|
|
26634
|
-
<header><div>${b.logoDataUri ? `<img src="${b.logoDataUri}" alt="" style="height:28px;margin-bottom:8px">` : ""}<h1>Design check</h1><div class="agency">${b.agencyName ? esc2(b.agencyName) + " \xB7 " : ""}${esc2(v.medium)} \xB7 ${esc2(v.target)} \xB7 ${esc2(v.at.slice(0, 16).replace("T", " "))} \xB7 ${v.renderedWith === "browser" ? "rendered in a browser" : "static checks only"}</div></div><div class="verdict ${verdictClass}">${v.verdict === "PASS" ? "Passes every measured rule" : v.verdict === "FAIL" ? "Fails a measured rule" : "Not graded"}</div></header>
|
|
26801
|
+
<header><div>${isRasterDataUri(b.logoDataUri) ? `<img src="${esc2(b.logoDataUri)}" alt="" style="height:28px;margin-bottom:8px">` : ""}<h1>Design check</h1><div class="agency">${b.agencyName ? esc2(b.agencyName) + " \xB7 " : ""}${esc2(v.medium)} \xB7 ${esc2(v.target)} \xB7 ${esc2(v.at.slice(0, 16).replace("T", " "))} \xB7 ${v.renderedWith === "browser" ? "rendered in a browser" : "static checks only"}</div></div><div class="verdict ${verdictClass}">${v.verdict === "PASS" ? "Passes every measured rule" : v.verdict === "FAIL" ? "Fails a measured rule" : "Not graded"}</div></header>
|
|
26635
26802
|
<p class="scope"><strong>What this report is not.</strong> Every rule here measures craft \u2014 type, spacing, colour, contrast, structure, performance, and the tells that make a page look machine-made. <strong>None of them measures whether the page persuades anyone.</strong> A page can pass every rule and still be boring, unconvincing and unsellable: no hook, no proof, no reason to care. Judge the writing separately, and never read a pass here as "this is a good page".</p>
|
|
26636
26803
|
<div class="conf ${v.conformance.ok ? "ok" : ""}"><b>${v.conformance.ok ? "The file honours the output contract." : "The file breaks the output contract, so the design rules were not graded."}</b>${v.conformance.problems.length ? `<ul>${v.conformance.problems.map((p) => `<li>${esc2(p)}</li>`).join("")}</ul>` : ""}${v.conformance.warnings.length ? `<div class="det">Warnings: ${v.conformance.warnings.map(esc2).join(" \xB7 ")}</div>` : ""}</div>
|
|
26637
26804
|
<div class="sum"><div><b>${s.measuredPass}</b><span>measured, pass</span></div><div><b>${s.measuredFail}</b><span>measured, fail</span></div><div><b>${s.proxyPass}</b><span>proxy, pass</span></div><div><b>${s.proxyFail}</b><span>proxy, fail</span></div><div><b>${s.asserted}</b><span>judgment calls</span></div><div><b>${s.na + s.unchecked}</b><span>not applicable / not checked</span></div></div>
|
|
@@ -26646,6 +26813,7 @@ var init_report2 = __esm({
|
|
|
26646
26813
|
"src/command-os/design/report.ts"() {
|
|
26647
26814
|
"use strict";
|
|
26648
26815
|
init_branding();
|
|
26816
|
+
init_svg_sanitize();
|
|
26649
26817
|
esc2 = (s) => String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
26650
26818
|
STATE_LABEL = {
|
|
26651
26819
|
"MEASURED-PASS": "Pass",
|
|
@@ -27428,8 +27596,14 @@ function registerDesignStudioTools(server2) {
|
|
|
27428
27596
|
d.provenance.evidence["shape.radius"] = `set by ${stamp}`;
|
|
27429
27597
|
}
|
|
27430
27598
|
if (a.logoSvg) {
|
|
27431
|
-
|
|
27432
|
-
|
|
27599
|
+
let clean;
|
|
27600
|
+
try {
|
|
27601
|
+
clean = sanitizeSvg(a.logoSvg);
|
|
27602
|
+
} catch (e) {
|
|
27603
|
+
throw new Error(`${e.message} Nothing was changed.`);
|
|
27604
|
+
}
|
|
27605
|
+
a.logoSvg = clean;
|
|
27606
|
+
d.logo = { inlineSvg: clean, whiteFillFixed: false, source: `set by ${stamp}`, rights: "declared" };
|
|
27433
27607
|
d.provenance.evidence["logo"] = `set by ${stamp}`;
|
|
27434
27608
|
}
|
|
27435
27609
|
if (a.tone) d.voice.tone = a.tone;
|
|
@@ -27655,6 +27829,7 @@ var init_design_studio = __esm({
|
|
|
27655
27829
|
init_fonts();
|
|
27656
27830
|
init_color();
|
|
27657
27831
|
init_branding();
|
|
27832
|
+
init_svg_sanitize();
|
|
27658
27833
|
summarise2 = (d) => ({
|
|
27659
27834
|
slug: d.slug,
|
|
27660
27835
|
name: d.name,
|
|
@@ -28820,9 +28995,9 @@ function registerConnectorTools(server2) {
|
|
|
28820
28995
|
links.push(...proposeClientLinks(result.customers, known, observedAt));
|
|
28821
28996
|
const timeZone = readAgencyProfile()?.defaults?.timezone;
|
|
28822
28997
|
const bridged = bridgeToEngagements(identities, links, observations, clients, { timeZone });
|
|
28823
|
-
const
|
|
28824
|
-
const owed = owedByClient(outstanding(result.invoices), identities, links,
|
|
28825
|
-
const abandoned = owedByClient(writtenOff(result.invoices), identities, links,
|
|
28998
|
+
const nameOf2 = (t) => clients[t]?.clientName ?? t;
|
|
28999
|
+
const owed = owedByClient(outstanding(result.invoices), identities, links, nameOf2);
|
|
29000
|
+
const abandoned = owedByClient(writtenOff(result.invoices), identities, links, nameOf2);
|
|
28826
29001
|
const renewals = renewalsDue(result.subs);
|
|
28827
29002
|
let written = 0;
|
|
28828
29003
|
let created = 0;
|
|
@@ -28864,7 +29039,7 @@ function registerConnectorTools(server2) {
|
|
|
28864
29039
|
stripeCustomerId: c.id,
|
|
28865
29040
|
email: c.email ?? null,
|
|
28866
29041
|
name: c.name ?? null,
|
|
28867
|
-
looksLike: hits.length === 1 ?
|
|
29042
|
+
looksLike: hits.length === 1 ? nameOf2(hits[0]) : null,
|
|
28868
29043
|
looksLikeClientKey: hits.length === 1 ? hits[0] : null
|
|
28869
29044
|
};
|
|
28870
29045
|
});
|
|
@@ -33396,7 +33571,7 @@ function runStage(locationId2, stage, onEvent, opts = {}) {
|
|
|
33396
33571
|
const permission = buildPermission(locationId2, { name: opts.name, approvedAt: opts.approvedAt });
|
|
33397
33572
|
if (!permission.allowed) return Promise.reject(new Error(`Refused: ${permission.reason}`));
|
|
33398
33573
|
const locationName = SANDBOX_ALLOWLIST[locationId2] || opts.name || locationId2;
|
|
33399
|
-
const spec = STAGE_SPECS[stage];
|
|
33574
|
+
const spec = opts.spec ?? STAGE_SPECS[stage];
|
|
33400
33575
|
if (!spec) return Promise.reject(new Error(`Stage ${stage} is not powered yet.`));
|
|
33401
33576
|
return new Promise((resolve11) => {
|
|
33402
33577
|
void (async () => {
|
|
@@ -33736,6 +33911,7 @@ var init_handoff_pack = __esm({
|
|
|
33736
33911
|
"use strict";
|
|
33737
33912
|
init_report_tokens();
|
|
33738
33913
|
import_zod69 = require("zod");
|
|
33914
|
+
init_svg_sanitize();
|
|
33739
33915
|
RunnerCheckSchema = import_zod69.z.object({
|
|
33740
33916
|
name: import_zod69.z.string().min(1),
|
|
33741
33917
|
state: import_zod69.z.enum(["CHECKED", "DONE_UNCHECKED"]),
|
|
@@ -34808,7 +34984,7 @@ function idFor(kind, what) {
|
|
|
34808
34984
|
for (let i = 0; i < what.length; i++) h = h * 31 + what.charCodeAt(i) >>> 0;
|
|
34809
34985
|
return `site-${kind}-${h.toString(36)}`;
|
|
34810
34986
|
}
|
|
34811
|
-
function
|
|
34987
|
+
function safeUrl3(u) {
|
|
34812
34988
|
const s = trim(u, 500);
|
|
34813
34989
|
if (!s) return "";
|
|
34814
34990
|
try {
|
|
@@ -34841,7 +35017,7 @@ function siteObservationsToFindings(input) {
|
|
|
34841
35017
|
refused.push({ what: "(no description)", reason: "the observation said nothing" });
|
|
34842
35018
|
continue;
|
|
34843
35019
|
}
|
|
34844
|
-
const evidence = (o.evidence ?? []).map((e) => ({ url:
|
|
35020
|
+
const evidence = (o.evidence ?? []).map((e) => ({ url: safeUrl3(e?.url), quote: trim(e?.quote, 300) })).filter((e) => e.url && e.quote);
|
|
34845
35021
|
if (o.kind === "present") {
|
|
34846
35022
|
if (!evidence.length) {
|
|
34847
35023
|
refused.push({ what, reason: "said the site does something, without quoting where" });
|
|
@@ -34865,7 +35041,7 @@ function siteObservationsToFindings(input) {
|
|
|
34865
35041
|
}
|
|
34866
35042
|
}
|
|
34867
35043
|
if (o.kind === "absent") {
|
|
34868
|
-
const checked = (o.checkedPages ?? []).map(
|
|
35044
|
+
const checked = (o.checkedPages ?? []).map(safeUrl3).filter(Boolean);
|
|
34869
35045
|
if (!checked.length) {
|
|
34870
35046
|
refused.push({ what, reason: "claimed something is missing without saying what was read \u2014 unverifiable, so it does not ship" });
|
|
34871
35047
|
continue;
|
|
@@ -34884,7 +35060,7 @@ function siteObservationsToFindings(input) {
|
|
|
34884
35060
|
remedy: remedyFor(kind),
|
|
34885
35061
|
fix: "manual",
|
|
34886
35062
|
minutes: kind === "contradiction" ? 15 : 30,
|
|
34887
|
-
evidence: kind === "contradiction" ? evidence.map((e) => ({ tool: "website", detail: `"${e.quote}" \u2014 ${e.url}` })) : (o.checkedPages ?? []).map(
|
|
35063
|
+
evidence: kind === "contradiction" ? evidence.map((e) => ({ tool: "website", detail: `"${e.quote}" \u2014 ${e.url}` })) : (o.checkedPages ?? []).map(safeUrl3).filter(Boolean).map((u) => ({ tool: "website", detail: `checked, not found: ${u}` }))
|
|
34888
35064
|
});
|
|
34889
35065
|
}
|
|
34890
35066
|
return {
|
|
@@ -36123,8 +36299,22 @@ async function submitIntakeAnswers(client, locationId2, snapshot, answers, conta
|
|
|
36123
36299
|
return { ok: false, unmapped, error: e instanceof Error ? e.message : String(e) };
|
|
36124
36300
|
}
|
|
36125
36301
|
}
|
|
36302
|
+
function escapeHtml2(s) {
|
|
36303
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
36304
|
+
}
|
|
36305
|
+
function intakeEmailHtml(to, formUrl, agencyName) {
|
|
36306
|
+
if (!/^https:\/\/[^\s"'<>]+$/.test(formUrl)) throw new Error("the form link is not a web address");
|
|
36307
|
+
return [
|
|
36308
|
+
`<p>Hi ${escapeHtml2(to.firstName?.trim() || "there")},</p>`,
|
|
36309
|
+
`<p>Before we build out your account, we need the details only you can give us. It takes about ten minutes, and you can stop and come back to it.</p>`,
|
|
36310
|
+
`<p><a href="${escapeHtml2(formUrl)}"><strong>Open your onboarding questionnaire</strong></a></p>`,
|
|
36311
|
+
`<p>Answer what you know. Anything you're unsure about, leave it and we'll cover it on our next call.</p>`,
|
|
36312
|
+
`<p>${escapeHtml2(agencyName)}</p>`
|
|
36313
|
+
].join("");
|
|
36314
|
+
}
|
|
36126
36315
|
async function emailIntakeLink(client, locationId2, to, formUrl, agencyName) {
|
|
36127
36316
|
try {
|
|
36317
|
+
const html2 = intakeEmailHtml(to, formUrl, agencyName);
|
|
36128
36318
|
const found = await client.get("/contacts/", { params: { locationId: locationId2, query: to.email, limit: 5 } });
|
|
36129
36319
|
let contactId = (found.contacts ?? []).find((c) => c.email?.toLowerCase() === to.email.toLowerCase())?.id;
|
|
36130
36320
|
if (!contactId) {
|
|
@@ -36134,13 +36324,6 @@ async function emailIntakeLink(client, locationId2, to, formUrl, agencyName) {
|
|
|
36134
36324
|
contactId = made.contact?.id;
|
|
36135
36325
|
}
|
|
36136
36326
|
if (!contactId) throw new Error("could not create a contact to email");
|
|
36137
|
-
const html2 = [
|
|
36138
|
-
`<p>Hi ${to.firstName || "there"},</p>`,
|
|
36139
|
-
`<p>Before we build out your account, we need the details only you can give us. It takes about ten minutes, and you can stop and come back to it.</p>`,
|
|
36140
|
-
`<p><a href="${formUrl}"><strong>Open your onboarding questionnaire</strong></a></p>`,
|
|
36141
|
-
`<p>Answer what you know. Anything you're unsure about, leave it and we'll cover it on our next call.</p>`,
|
|
36142
|
-
`<p>${agencyName}</p>`
|
|
36143
|
-
].join("");
|
|
36144
36327
|
await client.post("/conversations/messages", {
|
|
36145
36328
|
body: { type: "Email", contactId, emailTo: to.email, subject: "Your onboarding questionnaire", html: html2 }
|
|
36146
36329
|
});
|
|
@@ -36165,9 +36348,14 @@ function prefillTools(source, recorderPatterns = []) {
|
|
|
36165
36348
|
const deny = "Bash,Write,Edit,MultiEdit,NotebookEdit,Task";
|
|
36166
36349
|
if (source.kind === "text") return { allow: "", deny: deny + ",WebFetch,WebSearch,Read,Glob,Grep" };
|
|
36167
36350
|
if (source.kind === "url") return { allow: "WebFetch", deny: deny + ",WebSearch,Read,Glob,Grep" };
|
|
36351
|
+
if (source.kind === "file") return { allow: "Read", deny: deny + ",WebFetch,WebSearch,Glob,Grep" };
|
|
36168
36352
|
return { allow: recorderPatterns.join(","), deny: deny + ",Read,Glob,Grep" };
|
|
36169
36353
|
}
|
|
36170
36354
|
function sourcePreamble(source) {
|
|
36355
|
+
if (source.kind === "file") {
|
|
36356
|
+
return `Read the document at this exact path with the Read tool and use its contents as the material: ${source.path}
|
|
36357
|
+
It may be a PDF, a Word document or plain text (the file is "${source.name}"). If it cannot be read or is empty, say so \u2014 do not guess from the file name.`;
|
|
36358
|
+
}
|
|
36171
36359
|
if (source.kind === "url") {
|
|
36172
36360
|
return `Fetch this and use it as the material: ${source.url}
|
|
36173
36361
|
If it needs a login or returns nothing usable, say so \u2014 do not guess from the URL alone.`;
|
|
@@ -36204,7 +36392,7 @@ function prefillPromptForSource(labels, source) {
|
|
|
36204
36392
|
${body}` : body;
|
|
36205
36393
|
}
|
|
36206
36394
|
async function prefillFromSource(spawnClaude, labels, source, recorderPatterns = []) {
|
|
36207
|
-
const empty = source.kind === "text" && !source.text.trim() || source.kind === "url" && !source.url.trim() || source.kind === "recorder" && !source.instruction.trim();
|
|
36395
|
+
const empty = source.kind === "text" && !source.text.trim() || source.kind === "url" && !source.url.trim() || source.kind === "recorder" && !source.instruction.trim() || source.kind === "file" && !source.path.trim();
|
|
36208
36396
|
if (empty) return { ok: false, answers: {}, unknown: labels, error: "nothing to read" };
|
|
36209
36397
|
return runPrefill(() => spawnClaude(prefillPromptForSource(labels, source), prefillTools(source, recorderPatterns)), labels);
|
|
36210
36398
|
}
|
|
@@ -38928,10 +39116,10 @@ async function executeBackbone(plan, deps, opts = {}) {
|
|
|
38928
39116
|
}
|
|
38929
39117
|
return { ok: true, idMap, built, manual, published, publishFailed, deferred, staff, templates, templatesUnverified, templatesFormatNote, calendarPending, refreshed, missingSteps, recipients };
|
|
38930
39118
|
}
|
|
38931
|
-
async function buildSimple(objects, type, list2, create,
|
|
39119
|
+
async function buildSimple(objects, type, list2, create, nameOf2, pollForNew, idMap, built) {
|
|
38932
39120
|
if (objects.length === 0) return null;
|
|
38933
39121
|
for (const obj of objects) {
|
|
38934
|
-
const name =
|
|
39122
|
+
const name = nameOf2(obj);
|
|
38935
39123
|
let existing;
|
|
38936
39124
|
try {
|
|
38937
39125
|
existing = await list2();
|
|
@@ -40938,6 +41126,340 @@ var init_intake_contact = __esm({
|
|
|
40938
41126
|
}
|
|
40939
41127
|
});
|
|
40940
41128
|
|
|
41129
|
+
// src/command-os/client-capabilities.ts
|
|
41130
|
+
function findArray(raw, key) {
|
|
41131
|
+
if (Array.isArray(raw)) return raw;
|
|
41132
|
+
if (!raw || typeof raw !== "object") return null;
|
|
41133
|
+
const seen = /* @__PURE__ */ new Set();
|
|
41134
|
+
const byKey = (o, depth) => {
|
|
41135
|
+
if (!o || typeof o !== "object" || seen.has(o) || depth > 3) return null;
|
|
41136
|
+
seen.add(o);
|
|
41137
|
+
const r = o;
|
|
41138
|
+
if (key && Array.isArray(r[key])) return r[key];
|
|
41139
|
+
for (const v of Object.values(r)) {
|
|
41140
|
+
const hit = byKey(v, depth + 1);
|
|
41141
|
+
if (hit) return hit;
|
|
41142
|
+
}
|
|
41143
|
+
return null;
|
|
41144
|
+
};
|
|
41145
|
+
if (key) {
|
|
41146
|
+
const hit = byKey(raw, 0);
|
|
41147
|
+
if (hit) return hit;
|
|
41148
|
+
const r = raw;
|
|
41149
|
+
for (const env of ["data", "results"]) {
|
|
41150
|
+
const inner = r[env];
|
|
41151
|
+
if (Array.isArray(inner)) return inner;
|
|
41152
|
+
if (inner && typeof inner === "object" && Array.isArray(inner[key])) return inner[key];
|
|
41153
|
+
}
|
|
41154
|
+
return null;
|
|
41155
|
+
}
|
|
41156
|
+
seen.clear();
|
|
41157
|
+
const anyArray = (o, depth) => {
|
|
41158
|
+
if (!o || typeof o !== "object" || seen.has(o) || depth > 2) return null;
|
|
41159
|
+
seen.add(o);
|
|
41160
|
+
for (const v of Object.values(o)) if (Array.isArray(v)) return v;
|
|
41161
|
+
for (const v of Object.values(o)) {
|
|
41162
|
+
const hit = anyArray(v, depth + 1);
|
|
41163
|
+
if (hit) return hit;
|
|
41164
|
+
}
|
|
41165
|
+
return null;
|
|
41166
|
+
};
|
|
41167
|
+
return anyArray(raw, 0);
|
|
41168
|
+
}
|
|
41169
|
+
function capabilityByKey(key) {
|
|
41170
|
+
return CAPABILITIES.find((c) => c.key === key);
|
|
41171
|
+
}
|
|
41172
|
+
function nameOf(item, field) {
|
|
41173
|
+
if (!item || typeof item !== "object") return "";
|
|
41174
|
+
const o = item;
|
|
41175
|
+
for (const k of [field, "name", "title", "label", "agentName", "value", "phoneNumber"].filter(Boolean)) {
|
|
41176
|
+
const v = o[k];
|
|
41177
|
+
if (typeof v === "string" && v.trim()) return v.trim();
|
|
41178
|
+
if (v && typeof v === "object" && typeof v.singular === "string") return String(v.singular);
|
|
41179
|
+
}
|
|
41180
|
+
return "";
|
|
41181
|
+
}
|
|
41182
|
+
var CAPABILITY_GROUPS, N, AI2, CAPABILITIES, NO_BUILD_BUTTON;
|
|
41183
|
+
var init_client_capabilities = __esm({
|
|
41184
|
+
"src/command-os/client-capabilities.ts"() {
|
|
41185
|
+
"use strict";
|
|
41186
|
+
CAPABILITY_GROUPS = [
|
|
41187
|
+
{ key: "setup", label: "Set up", blurb: "The bones of the account: who, where, what it sells and where people book." },
|
|
41188
|
+
{ key: "messaging", label: "Messaging and automation", blurb: "What runs on its own once a lead arrives, and what goes out to the whole list." },
|
|
41189
|
+
{ key: "ai", label: "AI employee", blurb: "The bot that answers texts, the agent that answers calls, and the demo you show a prospect." },
|
|
41190
|
+
{ key: "money", label: "Money and paperwork", blurb: "Getting paid and getting it signed." },
|
|
41191
|
+
{ key: "reputation", label: "Reputation and content", blurb: "Reviews, social posts and the blog." },
|
|
41192
|
+
{ key: "health", label: "Health and handoff", blurb: "Proving it works, keeping it clean, and handing it over." }
|
|
41193
|
+
];
|
|
41194
|
+
N = (label, path36, params, key, nameField, version) => ({ label, path: path36, params, key, nameField, version });
|
|
41195
|
+
AI2 = "2021-04-15";
|
|
41196
|
+
CAPABILITIES = [
|
|
41197
|
+
// ── Set up ──────────────────────────────────────────────────────────────
|
|
41198
|
+
{
|
|
41199
|
+
key: "intake",
|
|
41200
|
+
group: "setup",
|
|
41201
|
+
label: "Intake and documents",
|
|
41202
|
+
what: "What the client told you, in one place: the intake answers, and the documents and links they sent.",
|
|
41203
|
+
says: "Show me {client}'s intake answers and what is still unanswered.",
|
|
41204
|
+
hub: "guides/blueprint-build-a-client-account.html",
|
|
41205
|
+
reads: [N("forms on the account", "/forms/", { locationId: "{loc}", limit: "100" }, "forms", "name")],
|
|
41206
|
+
blueprint: "The Blueprint intake form and its answers. Document and link uploads are the next build."
|
|
41207
|
+
},
|
|
41208
|
+
{
|
|
41209
|
+
key: "pipelines",
|
|
41210
|
+
group: "setup",
|
|
41211
|
+
label: "Pipelines and stages",
|
|
41212
|
+
what: "The board every lead moves across, from first contact to paid, with a stage for each step the client actually takes.",
|
|
41213
|
+
says: "Stand up a pipeline for {client} with the stages a new lead moves through, from first contact to paid.",
|
|
41214
|
+
hub: "guides/stand-up-a-pipeline.html",
|
|
41215
|
+
reads: [N("pipelines", "/opportunities/pipelines", { locationId: "{loc}" }, "pipelines", "name")],
|
|
41216
|
+
blueprint: "One pipeline, Patient Journey or the industry's equivalent, with seven stages."
|
|
41217
|
+
},
|
|
41218
|
+
{
|
|
41219
|
+
key: "calendars",
|
|
41220
|
+
group: "setup",
|
|
41221
|
+
label: "Calendars and booking",
|
|
41222
|
+
what: "Where people book: the calendar, its hours, who gets the appointment, and the reminders around it.",
|
|
41223
|
+
says: "Set up a booking calendar for {client}: consultation, 30 minutes, weekdays 9 to 5, round robin across the staff.",
|
|
41224
|
+
hub: "guides/set-up-a-booking-calendar.html",
|
|
41225
|
+
reads: [N("calendars", "/calendars/", { locationId: "{loc}" }, "calendars", "name")],
|
|
41226
|
+
blueprint: "One consultation calendar, round robin, 30-minute slots, pointed at the staff from the brief."
|
|
41227
|
+
},
|
|
41228
|
+
{
|
|
41229
|
+
key: "forms",
|
|
41230
|
+
group: "setup",
|
|
41231
|
+
label: "Forms and surveys",
|
|
41232
|
+
what: "The questions a lead answers before anyone calls them, and where those answers land.",
|
|
41233
|
+
says: "Build an intake form for {client} that asks for name, email, phone and what they are interested in, and show me where the answers land.",
|
|
41234
|
+
hub: "guides/create-forms-and-read-submissions.html",
|
|
41235
|
+
reads: [N("forms", "/forms/", { locationId: "{loc}", limit: "100" }, "forms", "name"), N("surveys", "/surveys/", { locationId: "{loc}", limit: "100" }, "surveys", "name")],
|
|
41236
|
+
blueprint: "One New Client Inquiry form, wired to the intake funnel."
|
|
41237
|
+
},
|
|
41238
|
+
{
|
|
41239
|
+
key: "pages",
|
|
41240
|
+
group: "setup",
|
|
41241
|
+
label: "Pages and funnels",
|
|
41242
|
+
what: "The website, the landing page and the funnel a lead lands on. Brands is only the shelf; this is where a page gets built.",
|
|
41243
|
+
says: "Build {client} a landing page for their main offer using their brand from the shelf, with a booking button that opens their calendar. Show me the draft before it goes live.",
|
|
41244
|
+
hub: "guides/page-studio-pages-and-sites.html",
|
|
41245
|
+
reads: [N("funnels", "/funnels/funnel/list", { locationId: "{loc}", limit: "50" }, "funnels", "name")],
|
|
41246
|
+
blueprint: "One intake funnel with the inquiry form on it. Full sites and offer pages are built here, one at a time."
|
|
41247
|
+
},
|
|
41248
|
+
{
|
|
41249
|
+
key: "fields",
|
|
41250
|
+
group: "setup",
|
|
41251
|
+
label: "Fields, values and tags",
|
|
41252
|
+
what: "The custom fields the account records, the values every message reads from, and the tags that move people between sequences.",
|
|
41253
|
+
says: "Show me {client}'s custom fields, custom values and tags, and add the ones the build needs.",
|
|
41254
|
+
hub: "guides/custom-fields-and-objects.html",
|
|
41255
|
+
reads: [
|
|
41256
|
+
N("custom fields", "/locations/{loc}/customFields", void 0, "customFields", "name"),
|
|
41257
|
+
N("custom values", "/locations/{loc}/customValues", void 0, "customValues", "name"),
|
|
41258
|
+
N("tags", "/locations/{loc}/tags", void 0, "tags", "name")
|
|
41259
|
+
],
|
|
41260
|
+
blueprint: "Six fields, four custom values and 24 tags, including the hand-off tags the sequences use."
|
|
41261
|
+
},
|
|
41262
|
+
{
|
|
41263
|
+
key: "staff",
|
|
41264
|
+
group: "setup",
|
|
41265
|
+
label: "Staff and users",
|
|
41266
|
+
what: "Who works in the account, what each one may see, and who gets the alerts and the appointments.",
|
|
41267
|
+
says: "Show me the staff on {client}'s account and who is assigned to the calendar and the alerts.",
|
|
41268
|
+
hub: "guides/account-admin-basics.html",
|
|
41269
|
+
reads: [N("users", "/users/", { locationId: "{loc}" }, "users", "name")],
|
|
41270
|
+
blueprint: "One user per staff member in the brief, added in GoHighLevel's own screen because the API refuses to create them."
|
|
41271
|
+
},
|
|
41272
|
+
{
|
|
41273
|
+
key: "phone",
|
|
41274
|
+
group: "setup",
|
|
41275
|
+
label: "Phone numbers and A2P",
|
|
41276
|
+
what: "The number the account texts and calls from, and the registration without which no text is delivered.",
|
|
41277
|
+
says: "Show me {client}'s phone numbers and whether A2P registration is done, and what is left to do by hand.",
|
|
41278
|
+
hub: "guides/account-admin-basics.html",
|
|
41279
|
+
reads: [N("phone numbers", "/phone-system/numbers/", { locationId: "{loc}" }, "phoneNumbers", "title")],
|
|
41280
|
+
blueprint: "Nothing. Numbers and A2P are bought and registered by hand in GoHighLevel; the build checks they exist."
|
|
41281
|
+
},
|
|
41282
|
+
// ── Messaging and automation ────────────────────────────────────────────
|
|
41283
|
+
{
|
|
41284
|
+
key: "workflows",
|
|
41285
|
+
group: "messaging",
|
|
41286
|
+
label: "Workflows and automations",
|
|
41287
|
+
what: "What happens on its own when a lead arrives, books, goes quiet or replies: speed to lead, missed-call text-back, nurture, reminders, no-show rescue.",
|
|
41288
|
+
says: "Show me {client}'s workflows and which of the standard eight are missing, then build the missing ones as drafts for my review.",
|
|
41289
|
+
hub: "guides/speed-to-lead.html",
|
|
41290
|
+
reads: [N("workflows", "/workflows/", { locationId: "{loc}" }, "workflows", "name")],
|
|
41291
|
+
blueprint: "Eight workflows: Speed to Lead, Lead Nurture, Win-back, Replied Stop and Route, Missed Call Text-Back, Confirm and Move, Reminders, No-Show Rescue."
|
|
41292
|
+
},
|
|
41293
|
+
{
|
|
41294
|
+
key: "reactivation",
|
|
41295
|
+
group: "messaging",
|
|
41296
|
+
label: "Reactivation campaigns",
|
|
41297
|
+
what: "Bringing a cold list back: one reason to return per message, over a month, to everyone who went quiet.",
|
|
41298
|
+
says: "Build a reactivation campaign for {client}: find everyone who has not booked in 90 days and run the win-back sequence on them, as a draft first.",
|
|
41299
|
+
hub: "guides/win-back-a-cold-list.html",
|
|
41300
|
+
reads: [N("workflows", "/workflows/", { locationId: "{loc}" }, "workflows", "name"), N("campaigns", "/campaigns/", { locationId: "{loc}" }, "campaigns", "name")],
|
|
41301
|
+
blueprint: "The Win-back sequence, five touches over 31 days, started by the winback-start tag."
|
|
41302
|
+
},
|
|
41303
|
+
{
|
|
41304
|
+
key: "templates",
|
|
41305
|
+
group: "messaging",
|
|
41306
|
+
label: "Email and SMS templates",
|
|
41307
|
+
what: "The messages themselves, written once in the client's voice and reused by every workflow and broadcast.",
|
|
41308
|
+
says: "Show me {client}'s email and text templates, and write the ones the sequences still need in their voice.",
|
|
41309
|
+
hub: "guides/build-email-templates.html",
|
|
41310
|
+
reads: [N("email templates", "/emails/builder", { locationId: "{loc}", limit: "100" }, "builders", "name")],
|
|
41311
|
+
blueprint: "Fifteen emails and seventeen texts, send-ready, one copy guide."
|
|
41312
|
+
},
|
|
41313
|
+
{
|
|
41314
|
+
key: "broadcasts",
|
|
41315
|
+
group: "messaging",
|
|
41316
|
+
label: "Broadcasts and email campaigns",
|
|
41317
|
+
what: "One message to a whole list, now or scheduled: an offer, an event, a newsletter.",
|
|
41318
|
+
says: "Draft an email to {client}'s list about their next event and show it to me. Do not send or schedule anything; I do that.",
|
|
41319
|
+
hub: "guides/send-texts-and-emails.html",
|
|
41320
|
+
reads: [N("scheduled emails", "/emails/schedule", { locationId: "{loc}", limit: "50" }, "schedules", "name"), N("email templates", "/emails/builder", { locationId: "{loc}", limit: "100" }, "builders", "name")]
|
|
41321
|
+
},
|
|
41322
|
+
// ── AI employee ─────────────────────────────────────────────────────────
|
|
41323
|
+
{
|
|
41324
|
+
key: "conversation-ai",
|
|
41325
|
+
group: "ai",
|
|
41326
|
+
label: "Conversation AI",
|
|
41327
|
+
what: "GoHighLevel's name for the bot that answers texts and chats from the client's own website and written answers.",
|
|
41328
|
+
says: "Build {client} a Conversation AI bot: read their website into a knowledge base, build the bot in suggestive mode, and show me its first answers.",
|
|
41329
|
+
hub: "command-os/ai-employee.html",
|
|
41330
|
+
reads: [
|
|
41331
|
+
N("knowledge bases", "/knowledge-bases/", { locationId: "{loc}", limit: "50" }, "knowledgeBases", "name", AI2),
|
|
41332
|
+
N("bots", "/conversation-ai/agents/search", { limit: "50" }, "agents", "name", AI2)
|
|
41333
|
+
]
|
|
41334
|
+
},
|
|
41335
|
+
{
|
|
41336
|
+
key: "voice-ai",
|
|
41337
|
+
group: "ai",
|
|
41338
|
+
label: "Voice AI",
|
|
41339
|
+
what: "GoHighLevel's name for the agent that answers the phone. Built without a number, given one on purpose, proven by the transcript.",
|
|
41340
|
+
says: "Check whether a call can land on {client}'s account, then build a Voice AI agent that answers, books and takes a message. Leave it unbound until I say.",
|
|
41341
|
+
hub: "modules/11-your-ai-employee/lesson-05-the-phone-agent.html",
|
|
41342
|
+
reads: [N("phone agents", "/voice-ai/agents", { locationId: "{loc}", page: "1", pageSize: "50" }, "agents", "agentName", AI2), N("phone numbers", "/phone-system/numbers/", { locationId: "{loc}" }, "phoneNumbers", "title")]
|
|
41343
|
+
},
|
|
41344
|
+
{
|
|
41345
|
+
key: "demo",
|
|
41346
|
+
group: "ai",
|
|
41347
|
+
label: "Prospect demo",
|
|
41348
|
+
what: "One sentence about a prospect's business becomes a bot they can text, on your own demo account, and one command puts it back. It messages a real prospect, so it runs from your own Claude with you watching, never from a button.",
|
|
41349
|
+
says: "Prepare an AI employee demo for {client} from their website and arm it so they can text it.",
|
|
41350
|
+
hub: "modules/11-your-ai-employee/lesson-04-the-five-minute-demo.html",
|
|
41351
|
+
reads: []
|
|
41352
|
+
},
|
|
41353
|
+
// ── Money and paperwork ─────────────────────────────────────────────────
|
|
41354
|
+
{
|
|
41355
|
+
key: "payments",
|
|
41356
|
+
group: "money",
|
|
41357
|
+
label: "Products, invoices and payments",
|
|
41358
|
+
what: "What the client sells, the invoices that ask for it, and the payments that came in.",
|
|
41359
|
+
says: "Show me {client}'s products and open invoices, and set up the three packages with payment links.",
|
|
41360
|
+
hub: "guides/invoices-and-payments.html",
|
|
41361
|
+
reads: [N("products", "/products/", { locationId: "{loc}", limit: "100" }, "products", "name"), N("invoices", "/invoices/", { altId: "{loc}", altType: "location", limit: "50", offset: "0" }, "invoices", "name")]
|
|
41362
|
+
},
|
|
41363
|
+
{
|
|
41364
|
+
key: "documents",
|
|
41365
|
+
group: "money",
|
|
41366
|
+
label: "Documents and signatures",
|
|
41367
|
+
what: "The templates a new patient agreement or a proposal is made from.",
|
|
41368
|
+
says: "Show me {client}'s document templates and tell me what a new patient agreement from the template would say. I press send.",
|
|
41369
|
+
hub: "guides/documents-and-contracts.html",
|
|
41370
|
+
reads: [N("document templates", "/proposals/templates", { locationId: "{loc}", limit: "50" }, "data", "name")]
|
|
41371
|
+
},
|
|
41372
|
+
{
|
|
41373
|
+
key: "memberships",
|
|
41374
|
+
group: "money",
|
|
41375
|
+
label: "Memberships and courses",
|
|
41376
|
+
what: "Courses, memberships and the content behind a paywall.",
|
|
41377
|
+
says: "Set up a members area for {client} with their program as a course, and show me the offer page.",
|
|
41378
|
+
hub: "guides/courses-and-memberships.html",
|
|
41379
|
+
reads: []
|
|
41380
|
+
},
|
|
41381
|
+
// ── Reputation and content ──────────────────────────────────────────────
|
|
41382
|
+
{
|
|
41383
|
+
key: "reviews",
|
|
41384
|
+
group: "reputation",
|
|
41385
|
+
label: "Reviews",
|
|
41386
|
+
what: "Asking for reviews at the right moment, and watching the ones that arrive.",
|
|
41387
|
+
says: "Show me {client}'s latest reviews and set up a review request after every completed appointment.",
|
|
41388
|
+
hub: "guides/watch-your-reviews.html",
|
|
41389
|
+
reads: []
|
|
41390
|
+
},
|
|
41391
|
+
{
|
|
41392
|
+
key: "social",
|
|
41393
|
+
group: "reputation",
|
|
41394
|
+
label: "Social posting",
|
|
41395
|
+
what: "Posts scheduled to the client's connected social accounts.",
|
|
41396
|
+
says: "Show me which social accounts {client} has connected, then write this week's posts for me to review. Nothing is posted until I say.",
|
|
41397
|
+
hub: "guides/social-posting.html",
|
|
41398
|
+
reads: [N("connected social accounts", "/social-media-posting/{loc}/accounts", void 0, "accounts", "name")]
|
|
41399
|
+
},
|
|
41400
|
+
{
|
|
41401
|
+
key: "blogs",
|
|
41402
|
+
group: "reputation",
|
|
41403
|
+
label: "Blogs",
|
|
41404
|
+
what: "The client's blog and its posts.",
|
|
41405
|
+
says: "Show me {client}'s blogs and draft a post on their most asked question.",
|
|
41406
|
+
hub: "guides/social-posting.html",
|
|
41407
|
+
reads: [N("blogs", "/blogs/site/all", { locationId: "{loc}", limit: "50" }, "data", "name")]
|
|
41408
|
+
},
|
|
41409
|
+
// ── Health and handoff ──────────────────────────────────────────────────
|
|
41410
|
+
{
|
|
41411
|
+
key: "checkup",
|
|
41412
|
+
group: "health",
|
|
41413
|
+
label: "Checkup and audit",
|
|
41414
|
+
what: "Whether the account works the way a lead would meet it, and whether every workflow can still be saved.",
|
|
41415
|
+
says: "Run a checkup on {client}'s account and audit every workflow, then tell me what is broken before the client finds it.",
|
|
41416
|
+
hub: "guides/run-a-checkup.html",
|
|
41417
|
+
reads: [N("workflows", "/workflows/", { locationId: "{loc}" }, "workflows", "name")],
|
|
41418
|
+
blueprint: "The Verify stage runs this after every build."
|
|
41419
|
+
},
|
|
41420
|
+
{
|
|
41421
|
+
key: "contacts",
|
|
41422
|
+
group: "health",
|
|
41423
|
+
label: "Contacts and data cleanup",
|
|
41424
|
+
what: "The people in the account, the duplicates, the tags, and the lists you report from.",
|
|
41425
|
+
says: "Show me how many contacts {client} has, find the duplicates, and tag everyone who has not booked in 90 days.",
|
|
41426
|
+
hub: "guides/bulk-tag-and-clean-up.html",
|
|
41427
|
+
reads: [N("tags", "/locations/{loc}/tags", void 0, "tags", "name")]
|
|
41428
|
+
},
|
|
41429
|
+
{
|
|
41430
|
+
key: "integrations",
|
|
41431
|
+
group: "health",
|
|
41432
|
+
label: "Integrations and webhooks",
|
|
41433
|
+
what: "Sending the account's data to other apps, tracked links, and custom objects.",
|
|
41434
|
+
says: "Set up a webhook so every new {client} lead lands in their spreadsheet, and give me a tracked link for the ad.",
|
|
41435
|
+
hub: "guides/send-data-to-other-apps.html",
|
|
41436
|
+
reads: [N("trigger links", "/links/", { locationId: "{loc}" }, "links", "name"), N("custom objects", "/objects/", { locationId: "{loc}" }, "objects", "labels")]
|
|
41437
|
+
},
|
|
41438
|
+
{
|
|
41439
|
+
key: "handoff",
|
|
41440
|
+
group: "health",
|
|
41441
|
+
label: "Go-live and handoff",
|
|
41442
|
+
what: "The final sign-off, the handoff page the client receives, and the snapshot that lets the build be reused.",
|
|
41443
|
+
says: "Build {client}'s handoff page and the offboarding kit, and tell me what is still waiting on someone else.",
|
|
41444
|
+
hub: "guides/snapshots-and-reusing-a-build.html",
|
|
41445
|
+
reads: [],
|
|
41446
|
+
blueprint: "Stage 7, Go-live, ends with the handoff page."
|
|
41447
|
+
}
|
|
41448
|
+
];
|
|
41449
|
+
NO_BUILD_BUTTON = /* @__PURE__ */ new Set(["demo", "documents", "social"]);
|
|
41450
|
+
}
|
|
41451
|
+
});
|
|
41452
|
+
|
|
41453
|
+
// src/command-os/script-json.ts
|
|
41454
|
+
function scriptJson(value) {
|
|
41455
|
+
return JSON.stringify(value === void 0 ? null : value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
41456
|
+
}
|
|
41457
|
+
var init_script_json = __esm({
|
|
41458
|
+
"src/command-os/script-json.ts"() {
|
|
41459
|
+
"use strict";
|
|
41460
|
+
}
|
|
41461
|
+
});
|
|
41462
|
+
|
|
40941
41463
|
// src/command-os/cockpit-shell.ts
|
|
40942
41464
|
function cockpitTokensCss(opts = {}) {
|
|
40943
41465
|
const webfont = opts.webfont !== false;
|
|
@@ -41020,21 +41542,74 @@ function cockpitChromeCss() {
|
|
|
41020
41542
|
.stepper button:disabled{opacity:.35;cursor:default}
|
|
41021
41543
|
.stepper output{font-size:var(--fs-label);min-width:2.6rem;text-align:center;color:var(--ink-2)}
|
|
41022
41544
|
footer{color:var(--ink-3);font-size:var(--fs-sm);text-align:center;margin:2rem 0;padding:0 1rem}
|
|
41023
|
-
|
|
41545
|
+
/* \u2500\u2500 The two-column shell: the hub's sidebar, ported (hub.css .side) \u2500\u2500 */
|
|
41546
|
+
.shell{display:flex;align-items:stretch;min-height:calc(100vh - 3rem)}
|
|
41547
|
+
.side{width:13.5rem;flex:none;box-sizing:border-box;background:var(--navy-deep);color:rgba(250,247,242,.74);padding:1rem .6rem 3rem;position:sticky;top:3rem;height:calc(100vh - 3rem);overflow-y:auto}
|
|
41548
|
+
.side-group{margin-bottom:1rem}
|
|
41549
|
+
.side-label{font-size:var(--fs-label);font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:rgba(250,247,242,.42);padding:.3rem .7rem}
|
|
41550
|
+
.side a,.side .active{display:flex;align-items:center;gap:.55rem;padding:.42rem .7rem;border-radius:.45rem;color:rgba(250,247,242,.78);font-size:var(--fs-sm);font-weight:500;text-decoration:none}
|
|
41551
|
+
.side a:hover{background:rgba(255,255,255,.08);color:#FAF7F2;text-decoration:none}
|
|
41552
|
+
.side .active{background:rgba(255,255,255,.09);color:#FAF7F2;box-shadow:inset .18rem 0 0 var(--gold-light)}
|
|
41553
|
+
.side .tag{margin-left:auto;font-size:var(--fs-label);font-weight:500;color:rgba(250,247,242,.5);white-space:nowrap}
|
|
41554
|
+
.side .tag.soon{color:#CEAB6A}
|
|
41555
|
+
.side .side-client{display:block;text-transform:none;letter-spacing:0;font-weight:500;color:#CEAB6A;margin-top:.15rem}
|
|
41556
|
+
.side a.sub,.side .active.sub{padding-left:1.5rem;font-size:var(--fs-label);font-weight:500}
|
|
41557
|
+
.side .active.grp{box-shadow:none;background:none;color:#FAF7F2}
|
|
41558
|
+
.side-foot{margin-top:.6rem;padding:.7rem .7rem;border-top:1px solid rgba(255,255,255,.09);font-size:var(--fs-label);color:rgba(250,247,242,.45);line-height:1.5;display:flex;flex-direction:column;gap:.3rem}
|
|
41559
|
+
.side-foot a{display:inline;padding:0;color:rgba(250,247,242,.78)}
|
|
41560
|
+
.content{flex:1;min-width:0}
|
|
41561
|
+
.topbar .screens{display:none}
|
|
41562
|
+
@media (max-width: 52rem){.brand-sub,.localpill{display:none}.side{display:none}.topbar .screens{display:flex;align-items:center;gap:.4rem}}
|
|
41024
41563
|
`;
|
|
41025
41564
|
}
|
|
41026
|
-
function
|
|
41565
|
+
function forThisClientHtml(capability) {
|
|
41566
|
+
const current = capability ? CAPABILITIES.find((c) => c.key === capability) : void 0;
|
|
41567
|
+
const rows = CAPABILITY_GROUPS.map((g) => {
|
|
41568
|
+
const caps = CAPABILITIES.filter((c) => c.group === g.key);
|
|
41569
|
+
const first = caps[0];
|
|
41570
|
+
const open = current?.group === g.key;
|
|
41571
|
+
const head3 = open ? `<span class="active grp" aria-current="true">${g.label}</span>` : `<a class="grp" href="/for/${first?.key ?? ""}" title="${g.blurb}">${g.label}</a>`;
|
|
41572
|
+
const subs = open ? caps.map((c) => c.key === capability ? `<span class="active sub" aria-current="page">${c.label}</span>` : `<a class="sub" href="/for/${c.key}" title="${c.what.replace(/"/g, """)}">${c.label}</a>`).join("\n ") : "";
|
|
41573
|
+
return head3 + (subs ? "\n " + subs : "");
|
|
41574
|
+
}).join("\n ");
|
|
41575
|
+
return `<div class="side-group"><div class="side-label">For this client <span id="side-client" class="side-client"></span></div>
|
|
41576
|
+
${rows}
|
|
41577
|
+
</div>`;
|
|
41578
|
+
}
|
|
41579
|
+
function cockpitSidebarHtml(active, capability) {
|
|
41580
|
+
const groups = SIDEBAR.map(([label, rows]) => {
|
|
41581
|
+
const links = rows.map((r) => {
|
|
41582
|
+
const tag = r.note ? `<span class="tag soon">${r.note}</span>` : r.hub ? `<span class="tag">hub ↗</span>` : "";
|
|
41583
|
+
return r.screen === active ? `<span class="active" aria-current="page">${r.label}</span>` : `<a href="${r.href}"${r.title ? ` title="${r.title}"` : ""}${r.hub ? ' target="_blank" rel="noopener"' : ""}>${r.label}${tag}</a>`;
|
|
41584
|
+
}).join("\n ");
|
|
41585
|
+
return `<div class="side-group"><div class="side-label">${label}</div>
|
|
41586
|
+
${links}
|
|
41587
|
+
</div>`;
|
|
41588
|
+
}).join("\n ");
|
|
41589
|
+
return `<aside class="side" aria-label="Command OS">
|
|
41590
|
+
${groups}
|
|
41591
|
+
${forThisClientHtml(capability)}
|
|
41592
|
+
<div class="side-foot"><a href="${HUB_URL}#do">← Back to hub</a><span>Runs on this computer. What you type stays here. What Claude reads for you goes to your own Claude account; a link or a call recording is fetched from its own site or recorder first. No copy comes to us.</span></div>
|
|
41593
|
+
</aside>`;
|
|
41594
|
+
}
|
|
41595
|
+
function cockpitHeaderHtml(active, opts = {}) {
|
|
41027
41596
|
const links = NAV.filter((n) => n.key !== active).map((n) => `<a class="backhub" href="${n.href}" title="${n.title}">${n.label}</a>`).join("");
|
|
41028
41597
|
const here = NAV.find((n) => n.key === active);
|
|
41029
41598
|
return `<header class="topbar">
|
|
41030
41599
|
<a class="brand" href="${HUB_URL}#do" title="Back to the member hub"><span class="brand-mark">G</span><span><span class="brand-name">GHL Command</span><span class="brand-sub"> Command OS</span></span></a>
|
|
41031
41600
|
<a class="backhub" href="${HUB_URL}#do">← Back to hub</a>
|
|
41032
41601
|
<div class="topbar-right">
|
|
41033
|
-
|
|
41602
|
+
<span class="screens">${here ? `<span class="backhub" aria-current="page">${here.label}</span>` : ""}${links}</span>
|
|
41034
41603
|
<span class="localpill">Runs on this computer</span>
|
|
41035
41604
|
<div class="stepper" role="group" aria-label="Type size"><button id="sz-down" type="button" aria-label="Smaller type">A−</button><output id="sz-val" aria-live="polite">${TYPE_DEFAULT}px</output><button id="sz-up" type="button" aria-label="Larger type">A+</button></div>
|
|
41036
41605
|
</div>
|
|
41037
|
-
</header
|
|
41606
|
+
</header>
|
|
41607
|
+
<div class="shell">
|
|
41608
|
+
${cockpitSidebarHtml(active, opts.capability)}
|
|
41609
|
+
<div class="content">`;
|
|
41610
|
+
}
|
|
41611
|
+
function cockpitShellClose() {
|
|
41612
|
+
return `</div></div>`;
|
|
41038
41613
|
}
|
|
41039
41614
|
function cockpitHeadHtml(title2, opts = {}) {
|
|
41040
41615
|
const fonts = opts.webfont === false ? "" : COCKPIT_FONT_LINKS;
|
|
@@ -41042,10 +41617,12 @@ function cockpitHeadHtml(title2, opts = {}) {
|
|
|
41042
41617
|
${fonts}
|
|
41043
41618
|
<style>${cockpitTokensCss(opts)}${cockpitChromeCss()}${opts.extraCss ?? ""}</style></head>`;
|
|
41044
41619
|
}
|
|
41045
|
-
var HUB_URL, COCKPIT_FONT_LINKS, TYPE_MIN, TYPE_MAX, TYPE_DEFAULT, TYPE_STORAGE_KEY, NAV, COCKPIT_TYPE_JS;
|
|
41620
|
+
var HUB_URL, COCKPIT_FONT_LINKS, TYPE_MIN, TYPE_MAX, TYPE_DEFAULT, TYPE_STORAGE_KEY, NAV, SIDEBAR, COCKPIT_TYPE_JS;
|
|
41046
41621
|
var init_cockpit_shell = __esm({
|
|
41047
41622
|
"src/command-os/cockpit-shell.ts"() {
|
|
41048
41623
|
"use strict";
|
|
41624
|
+
init_client_capabilities();
|
|
41625
|
+
init_script_json();
|
|
41049
41626
|
HUB_URL = "https://os.ghlcommand.com/";
|
|
41050
41627
|
COCKPIT_FONT_LINKS = `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600;9..40,700&family=Playfair+Display:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">`;
|
|
41051
41628
|
TYPE_MIN = 20;
|
|
@@ -41053,13 +41630,28 @@ var init_cockpit_shell = __esm({
|
|
|
41053
41630
|
TYPE_DEFAULT = 22;
|
|
41054
41631
|
TYPE_STORAGE_KEY = "cmdos-cockpit-fs";
|
|
41055
41632
|
NAV = [
|
|
41056
|
-
{ key: "board", href: "/", label: "Board", title: "
|
|
41057
|
-
{ key: "agency", href: "/agency", label: "Your agency", title: "Your agency's own details, used on every client" },
|
|
41058
|
-
{ key: "
|
|
41059
|
-
{ key: "
|
|
41633
|
+
{ key: "board", href: "/", label: "Board", title: "What is waiting on you, and every client account" },
|
|
41634
|
+
{ key: "agency", href: "/agency", label: "Your agency", title: "Your agency's own details, set up once and used on every client" },
|
|
41635
|
+
{ key: "assessment", href: "/assessment", label: "Assessment", title: "Run the paid assessment on a prospect. The answers stay on this computer until you save them to your own GoHighLevel account." },
|
|
41636
|
+
{ key: "brands", href: "/brands", label: "Brands", title: "The shelf every deck, page and report starts from" }
|
|
41637
|
+
];
|
|
41638
|
+
SIDEBAR = [
|
|
41639
|
+
["Start", [
|
|
41640
|
+
{ label: "Board", href: "/", screen: "board", title: NAV[0].title },
|
|
41641
|
+
{ label: "Your agency", href: "/agency", screen: "agency", title: NAV[1].title }
|
|
41642
|
+
]],
|
|
41643
|
+
["Clients", [
|
|
41644
|
+
{ label: "Assessment", href: "/assessment", screen: "assessment", title: NAV[2].title },
|
|
41645
|
+
{ label: "Build a Client Account", href: "/new", screen: "new", title: "Register the account, answer the intake, then run the seven stages" },
|
|
41646
|
+
{ label: "Work Queue", href: "/queue", screen: "queue", title: "The steps waiting on you, across every client" },
|
|
41647
|
+
{ label: "Client Builds", href: "/clients", screen: "clients", title: "Every client account and where its build stands" }
|
|
41648
|
+
]],
|
|
41649
|
+
["Design Studio", [
|
|
41650
|
+
{ label: "Brands", href: "/brands", screen: "brands", title: NAV[3].title }
|
|
41651
|
+
]]
|
|
41060
41652
|
];
|
|
41061
41653
|
COCKPIT_TYPE_JS = `/* Type size stepper \u2014 same behaviour as the hub (${TYPE_MIN}\u2013${TYPE_MAX}px, remembered). */
|
|
41062
|
-
(function(){var MIN=${TYPE_MIN},MAX=${TYPE_MAX},STEP=2,DEF=${TYPE_DEFAULT},KEY=${
|
|
41654
|
+
(function(){var MIN=${TYPE_MIN},MAX=${TYPE_MAX},STEP=2,DEF=${TYPE_DEFAULT},KEY=${scriptJson(TYPE_STORAGE_KEY)};
|
|
41063
41655
|
var down=document.getElementById("sz-down"),up=document.getElementById("sz-up"),val=document.getElementById("sz-val");
|
|
41064
41656
|
if(!down||!up||!val)return;
|
|
41065
41657
|
var size=DEF;try{var s=parseInt(localStorage.getItem(KEY),10);if(s>=MIN&&s<=MAX)size=s}catch(e){}
|
|
@@ -41073,6 +41665,8 @@ var init_cockpit_shell = __esm({
|
|
|
41073
41665
|
// src/agency-profile-page.ts
|
|
41074
41666
|
function agencyProfilePageHtml(p, clients = [], seatToken = "") {
|
|
41075
41667
|
const offers = (p.offers ?? []).map((o) => `${o.name}${o.priceFloor ? ` \u2014 ${o.priceFloor}${o.cadence === "monthly" ? "/mo" : ""}` : ""}`).join("\n");
|
|
41668
|
+
const os7 = p.agencyOs ?? {};
|
|
41669
|
+
const lines = (xs) => (xs ?? []).join("\n");
|
|
41076
41670
|
const sops = (p.delivery?.sops ?? []).join("\n");
|
|
41077
41671
|
const staff = (p.staff ?? []).map((m) => [m.name, m.role, m.email].filter(Boolean).join(" \xB7 ")).join("\n");
|
|
41078
41672
|
const clientBlocks = clients.map((c) => {
|
|
@@ -41139,21 +41733,21 @@ ${cockpitHeaderHtml("agency")}
|
|
|
41139
41733
|
<form id="f">
|
|
41140
41734
|
<fieldset><legend>Brand</legend>
|
|
41141
41735
|
<label for="agencyName">Agency name, as clients see it</label>
|
|
41142
|
-
<input id="agencyName" name="agencyName" value="${esc3(p.agencyName)}" placeholder="
|
|
41736
|
+
<input id="agencyName" name="agencyName" value="${esc3(p.agencyName)}" placeholder="Your Agency">
|
|
41143
41737
|
<div class="row">
|
|
41144
41738
|
<div><label for="accent">Brand colour</label><input type="color" id="accent" name="accent" value="${esc3(p.accent || "#b8934a")}"></div>
|
|
41145
|
-
<div><label for="website">Website</label><input id="website" name="website" value="${esc3(p.website)}" placeholder="https://
|
|
41739
|
+
<div><label for="website">Website</label><input id="website" name="website" value="${esc3(p.website)}" placeholder="https://youragency.com"></div>
|
|
41146
41740
|
</div>
|
|
41147
41741
|
<label for="logoUrl">Logo URL</label>
|
|
41148
|
-
<input id="logoUrl" name="logoUrl" value="${esc3(p.logoUrl)}" placeholder="https://
|
|
41742
|
+
<input id="logoUrl" name="logoUrl" value="${esc3(p.logoUrl)}" placeholder="https://youragency.com/logo.svg">
|
|
41149
41743
|
<p class="hint">Must start with https. A logo link that isn't https is refused, because this one goes on pages your prospects open.</p>
|
|
41150
41744
|
<label for="legalName">Registered entity, if different</label>
|
|
41151
|
-
<input id="legalName" name="legalName" value="${esc3(p.legalName)}" placeholder="
|
|
41745
|
+
<input id="legalName" name="legalName" value="${esc3(p.legalName)}" placeholder="Your Agency LLC \u2014 used on contracts and A2P">
|
|
41152
41746
|
</fieldset>
|
|
41153
41747
|
|
|
41154
41748
|
<fieldset><legend>Who messages come from</legend>
|
|
41155
41749
|
<div class="row">
|
|
41156
|
-
<div><label for="fromName">From name</label><input id="fromName" name="fromName" value="${esc3(p.sender?.fromName)}" placeholder="Dr
|
|
41750
|
+
<div><label for="fromName">From name</label><input id="fromName" name="fromName" value="${esc3(p.sender?.fromName)}" placeholder="Dr Pat Owner"></div>
|
|
41157
41751
|
<div><label for="replyTo">Reply-to email</label><input id="replyTo" name="replyTo" value="${esc3(p.sender?.replyTo)}"></div>
|
|
41158
41752
|
<div><label for="phone">Phone</label><input id="phone" name="phone" value="${esc3(p.sender?.phone)}"></div>
|
|
41159
41753
|
</div>
|
|
@@ -41171,11 +41765,61 @@ ${cockpitHeaderHtml("agency")}
|
|
|
41171
41765
|
<p class="hint">The floors are the numbers below which you don't take the work. Anything quoting a price checks them first.</p>
|
|
41172
41766
|
</fieldset>
|
|
41173
41767
|
|
|
41768
|
+
<fieldset><legend>Brand DNA \u2014 who you are (Agency OS build 1)</legend>
|
|
41769
|
+
<p class="hint">Filled in once, in your own words. Used on the assessment report, in proposals, in every Claude session's opening brief, and by the Agency OS builds.</p>
|
|
41770
|
+
<label for="story">Your story \u2014 why this agency exists, in two or three sentences</label>
|
|
41771
|
+
<textarea id="story" name="story" placeholder="I ran a clinic for twelve years and watched good practices lose patients to voicemail\u2026">${esc3(os7.story)}</textarea>
|
|
41772
|
+
<label for="method">Your method \u2014 how you get results, and what you call it</label>
|
|
41773
|
+
<textarea id="method" name="method" placeholder="The Full Room method: one weekend, a full calendar, a system that keeps booking after we leave">${esc3(os7.method)}</textarea>
|
|
41774
|
+
<div class="two">
|
|
41775
|
+
<div><label for="voice">How you sound \u2014 three words, then a sentence</label><textarea id="voice" name="voice" placeholder="Direct, warm, clinical. Specifics over hype.">${esc3(os7.voice)}</textarea></div>
|
|
41776
|
+
<div><label for="goals">Your goal for the next twelve months</label><textarea id="goals" name="goals" placeholder="$50k a month from 51 subscribers">${esc3(os7.goals)}</textarea></div>
|
|
41777
|
+
</div>
|
|
41778
|
+
<label for="values">What you stand for and what you refuse to do \u2014 one per line</label>
|
|
41779
|
+
<textarea id="values" name="values" placeholder="Never a false all-clear No cold texting anyone who did not opt in">${esc3(lines(os7.values))}</textarea>
|
|
41780
|
+
<label for="proof">Proof you may state, with permission \u2014 one per line</label>
|
|
41781
|
+
<textarea id="proof" name="proof" placeholder="Radiance Med Spa: 34 consults booked in one weekend (permission on file)">${esc3(lines(os7.proof))}</textarea>
|
|
41782
|
+
</fieldset>
|
|
41783
|
+
|
|
41784
|
+
<fieldset><legend>Ideal client \u2014 who you serve (build 2)</legend>
|
|
41785
|
+
<label for="icaWho">Who they are \u2014 role, business, size, where</label>
|
|
41786
|
+
<textarea id="icaWho" name="icaWho" placeholder="The owner of a two-provider med spa in a mid-size US city, doing $40k a month and answering the phone herself">${esc3(os7.ica?.who)}</textarea>
|
|
41787
|
+
<div class="two">
|
|
41788
|
+
<div><label for="icaPains">Their pains, worst first \u2014 one per line</label><textarea id="icaPains" name="icaPains">${esc3(lines(os7.ica?.pains))}</textarea></div>
|
|
41789
|
+
<div><label for="icaDesires">What they want, most first \u2014 one per line</label><textarea id="icaDesires" name="icaDesires">${esc3(lines(os7.ica?.desires))}</textarea></div>
|
|
41790
|
+
</div>
|
|
41791
|
+
<div class="two">
|
|
41792
|
+
<div><label for="icaObjections">Why they say no \u2014 one per line</label><textarea id="icaObjections" name="icaObjections">${esc3(lines(os7.ica?.objections))}</textarea></div>
|
|
41793
|
+
<div><label for="icaTriggers">What makes them buy now \u2014 one per line</label><textarea id="icaTriggers" name="icaTriggers">${esc3(lines(os7.ica?.triggers))}</textarea></div>
|
|
41794
|
+
</div>
|
|
41795
|
+
<div class="two">
|
|
41796
|
+
<div><label for="icaVocabulary">Their own words for the problem \u2014 one per line</label><textarea id="icaVocabulary" name="icaVocabulary">${esc3(lines(os7.ica?.vocabulary))}</textarea></div>
|
|
41797
|
+
<div><label for="icaHangouts">Where they read, listen and gather \u2014 one per line</label><textarea id="icaHangouts" name="icaHangouts">${esc3(lines(os7.ica?.hangouts))}</textarea></div>
|
|
41798
|
+
</div>
|
|
41799
|
+
<label for="icaVillains">What they have tried and blame \u2014 one per line</label>
|
|
41800
|
+
<textarea id="icaVillains" name="icaVillains" placeholder="The last agency that charged $3,000 a month for two posts a week">${esc3(lines(os7.ica?.villains))}</textarea>
|
|
41801
|
+
</fieldset>
|
|
41802
|
+
|
|
41803
|
+
<fieldset><legend>The offer \u2014 what you promise (build 3)</legend>
|
|
41804
|
+
<div class="two">
|
|
41805
|
+
<div><label for="offerName">The offer's name</label><input id="offerName" name="offerName" value="${esc3(os7.offer?.name)}" placeholder="The Full Room"></div>
|
|
41806
|
+
<div><label for="offerPrice">Price, and what it is per</label><input id="offerPrice" name="offerPrice" value="${esc3(os7.offer?.price)}" placeholder="$5,000 per event plus 30% of revenue"></div>
|
|
41807
|
+
</div>
|
|
41808
|
+
<label for="offerPromise">The promise \u2014 one sentence a prospect could repeat</label>
|
|
41809
|
+
<textarea id="offerPromise" name="offerPromise" placeholder="A full room in one weekend, and a system that keeps booking after we leave">${esc3(os7.offer?.promise)}</textarea>
|
|
41810
|
+
<label for="offerStack">What is in it \u2014 one per line</label>
|
|
41811
|
+
<textarea id="offerStack" name="offerStack" placeholder="The campaign page The AI receptionist The 30-day nurture">${esc3(lines(os7.offer?.valueStack))}</textarea>
|
|
41812
|
+
<div class="two">
|
|
41813
|
+
<div><label for="offerGuarantee">The guarantee, exactly as you would say it</label><textarea id="offerGuarantee" name="offerGuarantee">${esc3(os7.offer?.guarantee)}</textarea></div>
|
|
41814
|
+
<div><label for="offerAccelerators">Why now \u2014 bonuses, deadlines, scarcity, one per line</label><textarea id="offerAccelerators" name="offerAccelerators">${esc3(lines(os7.offer?.accelerators))}</textarea></div>
|
|
41815
|
+
</div>
|
|
41816
|
+
</fieldset>
|
|
41817
|
+
|
|
41174
41818
|
<fieldset><legend>How you deliver</legend>
|
|
41175
41819
|
<label for="sops">Delivery SOPs \u2014 one per line</label>
|
|
41176
41820
|
<textarea id="sops" name="sops" placeholder="Every build gets a rollback journal before go-live">${esc3(sops)}</textarea>
|
|
41177
41821
|
<label for="staff">Staff \u2014 one per line, "name \xB7 role \xB7 email"</label>
|
|
41178
|
-
<textarea id="staff" name="staff" placeholder="Dr
|
|
41822
|
+
<textarea id="staff" name="staff" placeholder="Dr Pat Owner \xB7 owner \xB7 pat@youragency.com">${esc3(staff)}</textarea>
|
|
41179
41823
|
<p class="hint">Who may approve what is recorded here for reference. The approval gates themselves are still enforced by the ritual, not by this list.</p>
|
|
41180
41824
|
</fieldset>
|
|
41181
41825
|
|
|
@@ -41198,12 +41842,12 @@ ${cockpitHeaderHtml("agency")}
|
|
|
41198
41842
|
</fieldset>
|
|
41199
41843
|
|
|
41200
41844
|
${clientsSection}
|
|
41201
|
-
</main
|
|
41845
|
+
</main>${cockpitShellClose()}
|
|
41202
41846
|
<script>
|
|
41203
41847
|
${READ_NUMBER_JS}
|
|
41204
41848
|
/* Served key first: this page is reachable on its own, so it cannot depend on
|
|
41205
41849
|
the board having been opened first in this browser. */
|
|
41206
|
-
const SERVED=${
|
|
41850
|
+
const SERVED=${scriptJson(seatToken)};
|
|
41207
41851
|
const KEY=SERVED||new URLSearchParams(location.search).get("seat")||localStorage.getItem("cockpit_seat")||"";
|
|
41208
41852
|
try{if(SERVED)localStorage.setItem("cockpit_seat",SERVED)}catch(e){}
|
|
41209
41853
|
/* The Anthropic key: saved on its own, and CHECKED before it is kept. A key that
|
|
@@ -41254,7 +41898,14 @@ document.getElementById("f").addEventListener("submit",async e=>{
|
|
|
41254
41898
|
offers:offers.length?offers:null,
|
|
41255
41899
|
pricing:{minMonthly:num("minMonthly"),minSetup:num("minSetup"),currency:v("currency")||null},
|
|
41256
41900
|
delivery:{sops:lines("sops").length?lines("sops"):null},
|
|
41257
|
-
staff:staff.length?staff:null
|
|
41901
|
+
staff:staff.length?staff:null,
|
|
41902
|
+
agencyOs:{story:v("story")||null,method:v("method")||null,voice:v("voice")||null,goals:v("goals")||null,
|
|
41903
|
+
values:lines("values").length?lines("values"):null,proof:lines("proof").length?lines("proof"):null,
|
|
41904
|
+
ica:{who:v("icaWho")||null,pains:lines("icaPains").length?lines("icaPains"):null,desires:lines("icaDesires").length?lines("icaDesires"):null,
|
|
41905
|
+
objections:lines("icaObjections").length?lines("icaObjections"):null,triggers:lines("icaTriggers").length?lines("icaTriggers"):null,
|
|
41906
|
+
vocabulary:lines("icaVocabulary").length?lines("icaVocabulary"):null,hangouts:lines("icaHangouts").length?lines("icaHangouts"):null,villains:lines("icaVillains").length?lines("icaVillains"):null},
|
|
41907
|
+
offer:{name:v("offerName")||null,promise:v("offerPromise")||null,price:v("offerPrice")||null,guarantee:v("offerGuarantee")||null,
|
|
41908
|
+
valueStack:lines("offerStack").length?lines("offerStack"):null,accelerators:lines("offerAccelerators").length?lines("offerAccelerators"):null}}};
|
|
41258
41909
|
const m=document.getElementById("msg");
|
|
41259
41910
|
const badNum=["minMonthly","minSetup"].find(id=>body.pricing[id]===undefined);
|
|
41260
41911
|
if(badNum){m.textContent="That is not a number I can read, so nothing was saved. Check the "+(badNum==="minMonthly"?"minimum monthly":"minimum setup fee")+".";return}
|
|
@@ -41302,6 +41953,7 @@ var init_agency_profile_page = __esm({
|
|
|
41302
41953
|
"use strict";
|
|
41303
41954
|
init_cockpit_shell();
|
|
41304
41955
|
init_client_engagements();
|
|
41956
|
+
init_script_json();
|
|
41305
41957
|
READ_NUMBER_JS = `function readNumber(raw){
|
|
41306
41958
|
var s=String(raw==null?"":raw).trim();
|
|
41307
41959
|
if(!s)return{clear:true};
|
|
@@ -41315,6 +41967,1067 @@ var init_agency_profile_page = __esm({
|
|
|
41315
41967
|
}
|
|
41316
41968
|
});
|
|
41317
41969
|
|
|
41970
|
+
// src/command-os/assessment/app-html.ts
|
|
41971
|
+
var ASSESSMENT_HTML;
|
|
41972
|
+
var init_app_html = __esm({
|
|
41973
|
+
"src/command-os/assessment/app-html.ts"() {
|
|
41974
|
+
"use strict";
|
|
41975
|
+
ASSESSMENT_HTML = `<!doctype html>
|
|
41976
|
+
<html lang="en">
|
|
41977
|
+
<head>
|
|
41978
|
+
<meta charset="utf-8">
|
|
41979
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
41980
|
+
<!-- The page had neither of the two lines above until 2026-08-30, and it holds
|
|
41981
|
+
~1,600 non-ASCII bytes. Chrome read it as windows-1252 in QUIRKS MODE, so
|
|
41982
|
+
every em-dash in the interview rendered as mojibake \u2014 including the two in
|
|
41983
|
+
the header beside the hours and money figures, which is the state an
|
|
41984
|
+
operator sees the moment they open it. The exported report always had both
|
|
41985
|
+
lines; only the shell that writes it was missing them. Caught by a browser
|
|
41986
|
+
pass, because nothing in the suite had ever loaded the page in a browser. -->
|
|
41987
|
+
<title>Assessment \u2014 Command OS</title>
|
|
41988
|
+
<!--COCKPIT_SHELL_CSS-->
|
|
41989
|
+
<style>
|
|
41990
|
+
:root{
|
|
41991
|
+
/* No webfont, and no palette of its own. This page is opened in a clinic with
|
|
41992
|
+
no wifi and handed to a client's accountant, so it may not fetch a stylesheet
|
|
41993
|
+
from Google \u2014 the cockpit injects the shared shell's tokens above this block
|
|
41994
|
+
with webfont:false, and these names are aliases onto them. Before the hub
|
|
41995
|
+
audit of 2026-09-04 this screen had its own dark-slate palette and read as a
|
|
41996
|
+
different product from the board. --ink, --warn and --shadow are NOT redefined
|
|
41997
|
+
here: they come from the shell under the same names. */
|
|
41998
|
+
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
|
|
41999
|
+
--serif:var(--font-display);--sans:var(--font-body);
|
|
42000
|
+
--ground:var(--bg);--panel:var(--bg-2);--panel-2:var(--surface-2);--paper:var(--surface);
|
|
42001
|
+
--muted:var(--ink-2);--rule:var(--line);--accent:var(--gold);--soft:var(--gold-soft);
|
|
42002
|
+
--stop:var(--rose);--go:var(--ok)}
|
|
42003
|
+
*{box-sizing:border-box}
|
|
42004
|
+
body{margin:0;background:var(--ground);color:var(--ink);font-family:var(--sans);
|
|
42005
|
+
font-size:1rem;line-height:1.5}
|
|
42006
|
+
/* Sticks BELOW the cockpit header (3rem tall, z-index 40), never over it:
|
|
42007
|
+
at z-index 50 and top:0 it covered the only way back to the board on scroll. */
|
|
42008
|
+
.top{background:var(--paper);border-bottom:1px solid var(--rule);padding:.6rem .9rem;position:sticky;top:3rem;z-index:30}
|
|
42009
|
+
.topin{max-width:60rem;margin:0 auto;display:flex;gap:1rem;align-items:baseline;flex-wrap:wrap}
|
|
42010
|
+
.topin h1{font-family:var(--serif);font-size:1.05rem;margin:0;font-weight:600}
|
|
42011
|
+
.who{color:var(--muted);font-size:var(--fs-sm)}
|
|
42012
|
+
.live{margin-left:auto;display:flex;gap:1rem;font-family:var(--mono);font-size:var(--fs-label)}
|
|
42013
|
+
.live b{display:block;font-size:.92rem;font-weight:500}
|
|
42014
|
+
.live .m{color:var(--stop)}.live .h{color:var(--accent)}.live .p{color:var(--muted)}
|
|
42015
|
+
.tabs{max-width:60rem;margin:0 auto;display:flex;gap:.3rem;padding:.5rem .9rem 0}
|
|
42016
|
+
.tabs button{background:none;border:none;border-bottom:2px solid transparent;padding:.3rem .5rem;
|
|
42017
|
+
cursor:pointer;font:inherit;font-size:var(--fs-sm);color:var(--muted)}
|
|
42018
|
+
.tabs button.on{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}
|
|
42019
|
+
.main{max-width:60rem;margin:0 auto;padding:.9rem;display:grid;grid-template-columns:13rem 1fr;gap:1rem}
|
|
42020
|
+
@media(max-width:760px){.main{grid-template-columns:1fr}}
|
|
42021
|
+
.rail{align-self:start;position:sticky;top:4.2rem}
|
|
42022
|
+
@media(max-width:760px){.rail{position:static;display:flex;overflow-x:auto;gap:.3rem}}
|
|
42023
|
+
.rail button{display:block;width:100%;text-align:left;background:none;border:none;cursor:pointer;font:inherit;
|
|
42024
|
+
font-size:var(--fs-sm);color:var(--muted);padding:.32rem .45rem;border-radius:.22rem;line-height:1.3}
|
|
42025
|
+
@media(max-width:760px){.rail button{white-space:nowrap;width:auto}}
|
|
42026
|
+
.rail button.on{background:var(--accent);color:var(--paper);font-weight:600}
|
|
42027
|
+
.rail button:hover:not(.on){background:var(--soft);color:var(--accent)}
|
|
42028
|
+
.rail .c{font-family:var(--mono);font-size:var(--fs-label);opacity:.75;float:right}
|
|
42029
|
+
.intro{color:var(--muted);font-size:var(--fs-sm);font-style:italic;margin:0 0 .8rem;max-width:60ch}
|
|
42030
|
+
h2{font-family:var(--serif);font-size:1.16rem;margin:0 0 .15rem;font-weight:600}
|
|
42031
|
+
.q{background:var(--paper);border:1px solid var(--rule);border-left:3px solid var(--rule);
|
|
42032
|
+
border-radius:.3rem;padding:.6rem .75rem;margin:.5rem 0}
|
|
42033
|
+
.q.done{border-left-color:var(--go)}.q.skip{border-left-color:var(--muted);opacity:.72}
|
|
42034
|
+
.q .ask{font-size:.9rem;font-weight:600;margin:0 0 .2rem}
|
|
42035
|
+
.q .probe{font-size:var(--fs-sm);color:var(--muted);font-style:italic;margin:0 0 .35rem}
|
|
42036
|
+
.q .why{font-size:var(--fs-sm);color:var(--muted);margin:.3rem 0 0;display:none}
|
|
42037
|
+
.q.open .why{display:block}
|
|
42038
|
+
.row{display:flex;gap:.4rem;align-items:center;flex-wrap:wrap}
|
|
42039
|
+
input.a,select.a,textarea.a{background:var(--ground);border:1px solid var(--rule);border-radius:.22rem;
|
|
42040
|
+
padding:.3rem .45rem;font:inherit;font-size:var(--fs-sm);color:var(--ink);max-width:11rem}
|
|
42041
|
+
textarea.a{max-width:100%;width:100%;min-height:2.6rem}
|
|
42042
|
+
input.a:focus,select.a:focus,textarea.a:focus{outline:2px solid var(--accent);outline-offset:1px}
|
|
42043
|
+
.unit{font-family:var(--mono);font-size:var(--fs-label);color:var(--muted)}
|
|
42044
|
+
.mini{background:none;border:1px solid var(--rule);border-radius:.2rem;padding:.2rem .45rem;cursor:pointer;
|
|
42045
|
+
font:inherit;font-size:var(--fs-sm);color:var(--muted)}
|
|
42046
|
+
.mini:hover{border-color:var(--accent);color:var(--accent)}
|
|
42047
|
+
.mini.on{background:var(--panel-2);color:var(--ink)}
|
|
42048
|
+
.card{background:var(--paper);border:1px solid var(--rule);border-radius:.35rem;padding:.8rem .9rem;
|
|
42049
|
+
margin:.6rem 0;box-shadow:var(--shadow)}
|
|
42050
|
+
table{border-collapse:collapse;width:100%;font-size:var(--fs-sm)}
|
|
42051
|
+
th,td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid var(--rule);vertical-align:top}
|
|
42052
|
+
th{font-size:var(--fs-label);text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600}
|
|
42053
|
+
.num{font-family:var(--mono);font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}
|
|
42054
|
+
th.num{text-align:right}
|
|
42055
|
+
.note{background:var(--panel);border-left:3px solid var(--accent);border-radius:.22rem;padding:.5rem .65rem;
|
|
42056
|
+
margin:.5rem 0;font-size:var(--fs-sm)}
|
|
42057
|
+
.note.warn{border-left-color:var(--warn)}.note.go{border-left-color:var(--go)}.note.stop{border-left-color:var(--stop)}
|
|
42058
|
+
.note b{display:block;margin-bottom:.1rem}
|
|
42059
|
+
.btn{background:var(--accent);color:var(--paper);border:none;border-radius:.22rem;padding:.4rem .8rem;
|
|
42060
|
+
font:inherit;font-size:var(--fs-sm);font-weight:600;cursor:pointer}
|
|
42061
|
+
.btn.ghost{background:none;color:var(--accent);border:1px solid var(--rule)}
|
|
42062
|
+
.btnrow{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.9rem}
|
|
42063
|
+
.assume{display:grid;grid-template-columns:1fr auto;gap:.5rem;align-items:center;padding:.35rem 0;
|
|
42064
|
+
border-bottom:1px solid var(--rule);font-size:var(--fs-sm)}
|
|
42065
|
+
.hide{display:none}
|
|
42066
|
+
</style>
|
|
42067
|
+
</head>
|
|
42068
|
+
<body>
|
|
42069
|
+
<!--COCKPIT_HEADER-->
|
|
42070
|
+
|
|
42071
|
+
<div class="top"><div class="topin">
|
|
42072
|
+
<h1 id="bizname">New assessment</h1>
|
|
42073
|
+
<span class="who" id="bizmeta"></span>
|
|
42074
|
+
<div class="live">
|
|
42075
|
+
<div class="p"><b id="lProg">0/46</b>answered</div>
|
|
42076
|
+
<div class="h"><b id="lHrs">—</b>hrs/week</div>
|
|
42077
|
+
<div class="m"><b id="lMon">—</b>/month</div>
|
|
42078
|
+
</div>
|
|
42079
|
+
</div></div>
|
|
42080
|
+
<div class="tabs">
|
|
42081
|
+
<button data-t="ask" class="on">Ask</button>
|
|
42082
|
+
<button data-t="set">Assumptions</button>
|
|
42083
|
+
<button data-t="find">Findings</button>
|
|
42084
|
+
<button data-t="rep">Report</button>
|
|
42085
|
+
</div>
|
|
42086
|
+
|
|
42087
|
+
<div class="main">
|
|
42088
|
+
<nav class="rail" id="rail"></nav>
|
|
42089
|
+
<div id="body"></div>
|
|
42090
|
+
</div>
|
|
42091
|
+
|
|
42092
|
+
<script>
|
|
42093
|
+
/**
|
|
42094
|
+
* The assessment engine \u2014 the ONE place the questions and the findings maths live.
|
|
42095
|
+
*
|
|
42096
|
+
* Plain JavaScript with a single export block at the end, because it has two
|
|
42097
|
+
* consumers with incompatible needs: the test suite imports it, and build-app.mjs
|
|
42098
|
+
* inlines it into app.html (which must stay a single self-contained file, since
|
|
42099
|
+
* Command OS serves it from the operator's own machine with no build step and no
|
|
42100
|
+
* network). The build strips the export block; nothing else about the file changes.
|
|
42101
|
+
*
|
|
42102
|
+
* It existed as two copies for one day \u2014 once here and once inside app.html \u2014 and
|
|
42103
|
+
* two copies of a rule that decides what a client is told is a defect waiting to
|
|
42104
|
+
* happen, so it is one copy now.
|
|
42105
|
+
*/
|
|
42106
|
+
|
|
42107
|
+
var AREAS = [
|
|
42108
|
+
["money", "What the work is worth", "Before anything else I need two numbers, because everything today gets multiplied by them."],
|
|
42109
|
+
["phone", "The phone", "Let's start where most of it goes."],
|
|
42110
|
+
["speed", "How fast anyone replies", "Now the enquiries that don't come by phone."],
|
|
42111
|
+
["repeat", "The same questions, again", "This one is about your time rather than your money."],
|
|
42112
|
+
["quotes", "After the quote goes out", "Walk me through what happens after you send a price."],
|
|
42113
|
+
["booking", "Getting them in the diary", "How does an appointment actually get made?"],
|
|
42114
|
+
["dormant", "Everyone who went quiet", "Now the cheapest money in the building, and the part nobody works."],
|
|
42115
|
+
["reviews", "What people see about you", "Your reviews, and how they happen."],
|
|
42116
|
+
["website", "What your website does", "What the site is actually for."],
|
|
42117
|
+
["found", "Whether you get found", "And how people arrive in the first place."],
|
|
42118
|
+
["week", "Your own week", "Last part, and it's about you rather than the business."]
|
|
42119
|
+
];
|
|
42120
|
+
|
|
42121
|
+
var Q = [
|
|
42122
|
+
["avg_value","money","When you win a job, what's it worth on average?","Give me the middle of it. Not your best month, not the tiny ones.","currency","Every finding today gets multiplied by this. Without it I can't put a number on anything."],
|
|
42123
|
+
["close_rate","money","When you actually get someone talking properly, how often does that turn into work?","Out of ten real conversations, how many buy?","rate","Turns a lost enquiry into a lost job rather than a lost phone call."],
|
|
42124
|
+
["margin_basis","money","Do you want me to work in what the job brings in, or what you keep?","","text","The report says which one it used, every time."],
|
|
42125
|
+
["capacity","money","If I found you thirty percent more work tomorrow, could you do it?","What breaks first, crews or trucks or you?","text","If they can't deliver more, the whole report points at margin and time instead of volume."],
|
|
42126
|
+
|
|
42127
|
+
["calls_month","phone","Roughly how many calls come in on a normal month?","Think about a busy Tuesday and work out from there.","count","The base for everything else on the phone."],
|
|
42128
|
+
["calls_missed","phone","How many of those go to voicemail or just ring out?","What happens when you're up a ladder and it rings?","count","The most common leak, and the one owners underestimate most."],
|
|
42129
|
+
["missed_never_return","phone","Of the ones you miss, how many never call back?","Half? More?","rate","This is what stops us overclaiming. We only count the ones you genuinely lose."],
|
|
42130
|
+
["who_answers","phone","Who answers the phone when it rings?","And what were they doing before it rang?","text","If it's you, this is a time finding as well as a money one."],
|
|
42131
|
+
["after_hours_calls","phone","What share of your calls come in outside working hours?","Evenings and weekends. Storm season?","rate","Separates a staffing problem from an automation one."],
|
|
42132
|
+
["voicemail_fate","phone","When someone does leave a voicemail, what happens to it?","","text","'I get to them at night' is the after-hours finding in their own words."],
|
|
42133
|
+
|
|
42134
|
+
["nonphone_month","speed","How many enquiries a month come in some other way? Form, text, Facebook.","","count","Counted separately from calls so nobody gets counted twice."],
|
|
42135
|
+
["first_response","speed","From one of those landing to a real person replying, how long is that usually?","Same hour? Same day? Next morning?","minutes","The gap between arriving and being answered is where enquiries go cold."],
|
|
42136
|
+
["waited_month","speed","How many a month sit overnight before anyone replies?","","count","The slow-response finding is priced on this count, not on your average."],
|
|
42137
|
+
["reply_channel","speed","When you do reply, do you call them or write back?","","text","Shapes what we'd build, not what we'd charge."],
|
|
42138
|
+
|
|
42139
|
+
["repeat_questions","repeat","What are the three questions you answer over and over every week?","The ones where you know the answer before they finish asking.","text","Every one of these is a job a machine does perfectly and you do resentfully."],
|
|
42140
|
+
["repeat_time","repeat","How much of your week goes on answering those?","","hours","Straight into the hours number."],
|
|
42141
|
+
["who_fields","repeat","Who fields them, you or someone else?","","text","If it's a person on payroll, this finding has a salary attached."],
|
|
42142
|
+
|
|
42143
|
+
["quotes_month","quotes","How many quotes or estimates go out in a month?","","count","These are your warmest people. They already talked to you."],
|
|
42144
|
+
["quotes_won","quotes","How many of those turn into work?","","count","The gap between sent and won is the follow-up finding."],
|
|
42145
|
+
["quote_followup","quotes","What happens to a quote after you send it?","Be honest. Does anything chase it, or is it on them to call you?","text","'Nothing' is the most common answer in this whole assessment."],
|
|
42146
|
+
["quote_time","quotes","How many hours a week go on putting quotes together?","","hours","Quoting is often the biggest single block of hours we find."],
|
|
42147
|
+
["quote_delay","quotes","How long between the site visit and them getting the price?","","text","A quote that arrives four days later competes with three that arrived first."],
|
|
42148
|
+
|
|
42149
|
+
["booked_month","booking","How many appointments or site visits get booked in a month?","","count","The base for the no-show finding."],
|
|
42150
|
+
["show_rate","booking","Of those, how many actually happen?","Nobody home, cancelled last minute, forgot.","rate","A no-show is a job you paid to get twice."],
|
|
42151
|
+
["reminders","booking","Does anything remind them before the appointment?","","text","If the answer is 'I text them myself', that's both a time and a money finding."],
|
|
42152
|
+
["noshow_fate","booking","When someone no-shows, what happens next?","","text","'Nothing' means the rebooking finding is real."],
|
|
42153
|
+
|
|
42154
|
+
["dormant_count","dormant","How many people are sitting in your system who enquired once and never went anywhere?","Old quotes, old customers, people who called in 2023.","count","These people already raised their hand once. The cheapest money you have."],
|
|
42155
|
+
["dormant_worked","dormant","Has anyone ever gone back to that list?","","text","If nothing has ever been sent, the whole list is untouched revenue."],
|
|
42156
|
+
["past_customers","dormant","How many are past customers rather than people who never bought?","","count","Past customers convert far better, and they need a different message."],
|
|
42157
|
+
["repeat_cycle","dormant","How often would a past customer normally need you again?","","text","Decides whether reactivation is a one-off campaign or a standing programme."],
|
|
42158
|
+
|
|
42159
|
+
["review_count","reviews","How many Google reviews do you have?","","count","The number a new customer sees before they call you."],
|
|
42160
|
+
["review_ask","reviews","How do you ask for a review at the moment?","Who remembers to do it, and when?","text","'When I remember' is why the newest one is months old."],
|
|
42161
|
+
["review_reply","reviews","Do you reply to them?","","text","An unanswered bad review is the first thing a new customer reads."],
|
|
42162
|
+
["review_time","reviews","How many hours a week go on chasing reviews?","","hours","Small on its own. Adds up with the rest."],
|
|
42163
|
+
|
|
42164
|
+
["site_job","website","What do you want someone to do when they land on your site?","Call, book, fill something in?","text","Most sites do not do the thing the owner assumes they do."],
|
|
42165
|
+
["site_capture","website","Can someone book you from the website without speaking to anyone?","","text","If not, every after-hours visitor is gone."],
|
|
42166
|
+
["site_leads","website","How many enquiries a month come from the site itself?","","count","Against traffic this gives the conversion finding, scoped to your trade."],
|
|
42167
|
+
["site_age","website","When was the site last touched?","","text","Decides rebuild versus repair."],
|
|
42168
|
+
|
|
42169
|
+
["lead_sources","found","Where does your work actually come from? Rank them.","Word of mouth, Google, Facebook, repeat, trucks?","text","Tells us which leak matters most before we price any of them."],
|
|
42170
|
+
["paid_spend","found","Are you spending anything on advertising each month?","","currency","If they are, every leak we find is also wasted ad spend."],
|
|
42171
|
+
["gbp_status","found","Do you manage your Google listing yourself?","","text","The cheapest visibility fix there is, and usually neglected."],
|
|
42172
|
+
|
|
42173
|
+
["admin_hours","week","How many hours a week go on paperwork and admin rather than actual work?","Including the bits you do at night.","hours","The number the whole hours promise is measured against."],
|
|
42174
|
+
["evening_work","week","How much of that happens after dinner?","","hours","The answer that makes owners sit up. Part of the number above, not extra."],
|
|
42175
|
+
["stop_doing","week","If you could stop doing one thing tomorrow and never do it again, what is it?","","text","Their priority in their words. It leads the roadmap even if it isn't the biggest number."],
|
|
42176
|
+
["team_size","week","Who else is in the business?","","count","Decides whether a fix is automation or delegation."],
|
|
42177
|
+
["growth_goal","week","What would a good twelve months look like?","A number, if you have one.","text","The report closes against their goal, not ours."]
|
|
42178
|
+
];
|
|
42179
|
+
|
|
42180
|
+
var ASSUMPTIONS = [
|
|
42181
|
+
["recovery","How much of the leak we actually stop",0.5,"Assumes we recover half of what is leaking. Your number, and the most important one here."],
|
|
42182
|
+
["quote_recovery","Chasing an unclosed quote recovers",0.10,"Assumes chasing recovers 1 in 10. Your number, not ours."],
|
|
42183
|
+
["response_penalty","Fall-off once an enquiry has waited",0.25,"Assumes a 25% fall-off once they have waited. Your number."],
|
|
42184
|
+
["noshow_recovery","A no-show that gets rebooked",0.50,"Assumes half could be rebooked. Your number."],
|
|
42185
|
+
["reactivation","Dormant contacts who respond",0.02,"Assumes 2% come back. Your number. One-off, not annual."]
|
|
42186
|
+
];
|
|
42187
|
+
|
|
42188
|
+
/**
|
|
42189
|
+
* Hours questions that feed the weekly total. evening_work is deliberately NOT
|
|
42190
|
+
* here: it asks how much of admin_hours happens after dinner, so adding it would
|
|
42191
|
+
* count the same hours twice. It is a headline, not an addend.
|
|
42192
|
+
*/
|
|
42193
|
+
var HOUR_KEYS = [
|
|
42194
|
+
["repeat_time", "Answering the same questions"],
|
|
42195
|
+
["quote_time", "Putting quotes together"],
|
|
42196
|
+
["review_time", "Chasing reviews"],
|
|
42197
|
+
["admin_hours", "Paperwork and admin"]
|
|
42198
|
+
];
|
|
42199
|
+
|
|
42200
|
+
/** The two answers without which nothing at all can be priced. */
|
|
42201
|
+
var PRICING_CORE = ["avg_value", "close_rate"];
|
|
42202
|
+
|
|
42203
|
+
/**
|
|
42204
|
+
* What a missing input is called in the PROSPECT'S document.
|
|
42205
|
+
*
|
|
42206
|
+
* The report printed the raw question id \u2014 "close_rate" \u2014 into a page going out
|
|
42207
|
+
* on the operator's letterhead. These are the same measurements written as
|
|
42208
|
+
* instructions, because "what to measure first" is a list of things to go and
|
|
42209
|
+
* count, not a list of fields.
|
|
42210
|
+
*
|
|
42211
|
+
* Every id that can reach O[].missing must have an entry. engine.test.ts asserts
|
|
42212
|
+
* that coverage, so a new priced finding cannot ship a raw id into a client
|
|
42213
|
+
* document by forgetting this table.
|
|
42214
|
+
*/
|
|
42215
|
+
var MEASURE = {
|
|
42216
|
+
avg_value: "What one won job is worth to you, on average",
|
|
42217
|
+
close_rate: "Out of ten real conversations, how many buy",
|
|
42218
|
+
calls_missed: "Calls a month that go to voicemail or ring out",
|
|
42219
|
+
missed_never_return: "How many of those callers never try again",
|
|
42220
|
+
waited_month: "Enquiries a month that sit overnight before anyone replies",
|
|
42221
|
+
quotes_month: "Quotes sent in a month",
|
|
42222
|
+
quotes_won: "How many of those quotes turned into work",
|
|
42223
|
+
booked_month: "Appointments booked in a month",
|
|
42224
|
+
show_rate: "How many booked appointments actually happen"
|
|
42225
|
+
};
|
|
42226
|
+
|
|
42227
|
+
/**
|
|
42228
|
+
* Which of the two load-bearing numbers are not usable yet.
|
|
42229
|
+
*
|
|
42230
|
+
* Module-level and exported because the INTERVIEW needs this, not only the
|
|
42231
|
+
* report. Every finding multiplies by both, so one blank here voids the whole
|
|
42232
|
+
* document \u2014 and the app used to let an operator answer all 46 questions before
|
|
42233
|
+
* anything said so. compute() derives \`core\` from this same function rather than
|
|
42234
|
+
* repeating the rule, because two copies of a rule that decides what a client is
|
|
42235
|
+
* told is a defect waiting to happen.
|
|
42236
|
+
*/
|
|
42237
|
+
function coreMissing(ans, skip) {
|
|
42238
|
+
ans = ans || {}; skip = skip || {};
|
|
42239
|
+
var out = [];
|
|
42240
|
+
PRICING_CORE.forEach(function (id) {
|
|
42241
|
+
if (skip[id]) { out.push(id); return; }
|
|
42242
|
+
var v = ans[id];
|
|
42243
|
+
if (v === undefined || v === null || String(v).trim() === "") { out.push(id); return; }
|
|
42244
|
+
var n = parseTyped(v, UNIT[id]);
|
|
42245
|
+
if (!isFinite(n) || n <= 0) { out.push(id); return; }
|
|
42246
|
+
// A share is only a share inside 0..1 once scaled; 400% is not a close rate.
|
|
42247
|
+
if (UNIT[id] === "rate") { var r = n > 1 ? n / 100 : n; if (!(r > 0 && r <= 1)) out.push(id); }
|
|
42248
|
+
});
|
|
42249
|
+
return out;
|
|
42250
|
+
}
|
|
42251
|
+
|
|
42252
|
+
/** id -> the measurement in words. Never returns the id: an unlabelled id is a bug, not a fallback. */
|
|
42253
|
+
function measureLabel(id) {
|
|
42254
|
+
return MEASURE[id] || null;
|
|
42255
|
+
}
|
|
42256
|
+
|
|
42257
|
+
/** Ids -> labels, dropping any we cannot name rather than printing machine text at a stranger. */
|
|
42258
|
+
function measureLabels(ids) {
|
|
42259
|
+
var out = [];
|
|
42260
|
+
for (var i = 0; i < ids.length; i++) {
|
|
42261
|
+
var l = measureLabel(ids[i]);
|
|
42262
|
+
if (l && out.indexOf(l) === -1) out.push(l);
|
|
42263
|
+
}
|
|
42264
|
+
return out;
|
|
42265
|
+
}
|
|
42266
|
+
|
|
42267
|
+
/** id -> unit, so number parsing can be unit-aware. Built from Q, never hand-listed. */
|
|
42268
|
+
var UNIT = {};
|
|
42269
|
+
Q.forEach(function (q) { UNIT[q[0]] = q[4]; });
|
|
42270
|
+
|
|
42271
|
+
/**
|
|
42272
|
+
* Parse a number the way the person typing it meant it.
|
|
42273
|
+
*
|
|
42274
|
+
* The trap this exists for: an owner outside the US types 6.800 meaning six
|
|
42275
|
+
* thousand eight hundred. Read naively that is 6.8, and the report tells them
|
|
42276
|
+
* their business leaks $24 a month instead of $24,412 \u2014 plausible, silent, and
|
|
42277
|
+
* completely wrong, on their own letterhead. So a dot used as a THOUSANDS
|
|
42278
|
+
* separator (digits in exact groups of three) is stripped, and only for units
|
|
42279
|
+
* where that grouping is possible. A rate is never grouped, so 0.500 stays 0.5.
|
|
42280
|
+
*/
|
|
42281
|
+
function parseTyped(text, unit) {
|
|
42282
|
+
var t = String(text).trim().replace(/[^0-9.,\\-]/g, "");
|
|
42283
|
+
if (t === "" || t === "-") return NaN;
|
|
42284
|
+
var grouped = unit !== "rate";
|
|
42285
|
+
if (grouped && /^-?\\d{1,3}(\\.\\d{3})+$/.test(t)) t = t.replace(/\\./g, ""); // 6.800 -> 6800
|
|
42286
|
+
if (grouped && /^-?\\d{1,3}(,\\d{3})+(\\.\\d+)?$/.test(t)) t = t.replace(/,/g, ""); // 6,800.50 -> 6800.50
|
|
42287
|
+
t = t.replace(/,/g, "."); // 6,5 -> 6.5
|
|
42288
|
+
var parts = t.split(".");
|
|
42289
|
+
if (parts.length > 2) return NaN; // 6.800.500 is not a number we will guess at
|
|
42290
|
+
return parseFloat(t);
|
|
42291
|
+
}
|
|
42292
|
+
|
|
42293
|
+
/**
|
|
42294
|
+
* Read an assumption the operator typed, in the same way an answer is read.
|
|
42295
|
+
*
|
|
42296
|
+
* These went through a bare parseFloat until 2026-08-30, which meant an operator
|
|
42297
|
+
* typing 0,5 got 0 \u2014 and an assumption of zero DROPS its finding. So the report
|
|
42298
|
+
* quietly lost a finding because of a comma, and said nothing about it. Same
|
|
42299
|
+
* class as the 6.800 bug, one screen over.
|
|
42300
|
+
*/
|
|
42301
|
+
function parseRate(text) {
|
|
42302
|
+
var n = parseTyped(text, "rate");
|
|
42303
|
+
if (!isFinite(n) || n < 0) return 0;
|
|
42304
|
+
return n > 1 ? n / 100 : n;
|
|
42305
|
+
}
|
|
42306
|
+
|
|
42307
|
+
/**
|
|
42308
|
+
* Compute findings from raw answers.
|
|
42309
|
+
* ans { id: value as typed }
|
|
42310
|
+
* skip { id: true } when the operator marked it unknown
|
|
42311
|
+
* asm { id: rate as a decimal }
|
|
42312
|
+
* A value is only usable when it is present, not skipped, and finite. Anything
|
|
42313
|
+
* else is treated as unknown, and an unknown OMITS its finding rather than
|
|
42314
|
+
* being defaulted from the industry.
|
|
42315
|
+
*/
|
|
42316
|
+
function compute(ans, skip, asm) {
|
|
42317
|
+
ans = ans || {}; skip = skip || {}; asm = asm || {};
|
|
42318
|
+
|
|
42319
|
+
function raw(id) {
|
|
42320
|
+
if (skip[id]) return null;
|
|
42321
|
+
var v = ans[id];
|
|
42322
|
+
if (v === undefined || v === null || String(v).trim() === "") return null;
|
|
42323
|
+
var n = parseTyped(v, UNIT[id]);
|
|
42324
|
+
return isFinite(n) ? n : null;
|
|
42325
|
+
}
|
|
42326
|
+
/** A share typed as either 28 or 0.28. Anything outside 0..1 after scaling is not a share. */
|
|
42327
|
+
function share(id) {
|
|
42328
|
+
var n = raw(id);
|
|
42329
|
+
if (n === null) return null;
|
|
42330
|
+
var r = n > 1 ? n / 100 : n;
|
|
42331
|
+
return r >= 0 && r <= 1 ? r : null;
|
|
42332
|
+
}
|
|
42333
|
+
function a(k) { var n = Number(asm[k]); return isFinite(n) && n > 0 ? n : 0; }
|
|
42334
|
+
|
|
42335
|
+
var v = raw("avg_value"), c = share("close_rate");
|
|
42336
|
+
var coreGaps = coreMissing(ans, skip);
|
|
42337
|
+
var core = coreGaps.length === 0;
|
|
42338
|
+
var F = [], O = [];
|
|
42339
|
+
|
|
42340
|
+
/**
|
|
42341
|
+
* The ONE place an omission is recorded. Every push goes through here so a new
|
|
42342
|
+
* finding cannot ship a bare id list: the labels are attached where the ids
|
|
42343
|
+
* are, not at the render site that happens to remember.
|
|
42344
|
+
*/
|
|
42345
|
+
function omit(o) {
|
|
42346
|
+
o.missing = o.missing || [];
|
|
42347
|
+
o.missingLabels = measureLabels(o.missing);
|
|
42348
|
+
O.push(o);
|
|
42349
|
+
return o;
|
|
42350
|
+
}
|
|
42351
|
+
|
|
42352
|
+
function add(id, title, needs, assumption, fn, fix) {
|
|
42353
|
+
var missing = needs.filter(function (n) {
|
|
42354
|
+
return (n.share ? share(n.id) : raw(n.id)) === null;
|
|
42355
|
+
}).map(function (n) { return n.id; });
|
|
42356
|
+
if (!core) { omit({ id: id, title: title, missing: coreGaps }); return; }
|
|
42357
|
+
if (missing.length) { omit({ id: id, title: title, missing: missing }); return; }
|
|
42358
|
+
if (assumption && a(assumption) === 0) { omit({ id: id, title: title, missing: [], declined: assumption }); return; }
|
|
42359
|
+
var m = fn();
|
|
42360
|
+
if (!isFinite(m) || m < 0) { omit({ id: id, title: title, missing: needs.map(function (n) { return n.id; }) }); return; }
|
|
42361
|
+
// Zero is not a finding. Listing "Quotes nobody chases \u2014 $0" pads a client
|
|
42362
|
+
// document with things that are not happening.
|
|
42363
|
+
if (m === 0) { omit({ id: id, title: title, missing: [], reason: "nothing to price here on your numbers" }); return; }
|
|
42364
|
+
F.push({ id: id, title: title, monthly: m, assumption: assumption || null, fix: fix });
|
|
42365
|
+
}
|
|
42366
|
+
|
|
42367
|
+
add("missed", "Calls you never get back to",
|
|
42368
|
+
[{ id: "calls_missed" }, { id: "missed_never_return", share: true }], null,
|
|
42369
|
+
function () { return raw("calls_missed") * share("missed_never_return") * c * v; },
|
|
42370
|
+
"Call answering, day and night");
|
|
42371
|
+
|
|
42372
|
+
add("waited", "Enquiries that waited",
|
|
42373
|
+
[{ id: "waited_month" }], "response_penalty",
|
|
42374
|
+
function () { return raw("waited_month") * a("response_penalty") * c * v; },
|
|
42375
|
+
"Instant reply on every channel");
|
|
42376
|
+
|
|
42377
|
+
// quotes_won > quotes_month means the two answers disagree. Clamping to zero
|
|
42378
|
+
// would hide a contradiction the operator needs to go back and resolve.
|
|
42379
|
+
if (core && raw("quotes_month") !== null && raw("quotes_won") !== null && raw("quotes_won") > raw("quotes_month")) {
|
|
42380
|
+
omit({ id: "quotes", title: "Quotes nobody chases", missing: [],
|
|
42381
|
+
reason: "you told us more quotes were won than were sent \u2014 worth checking before this is priced" });
|
|
42382
|
+
} else {
|
|
42383
|
+
add("quotes", "Quotes nobody chases",
|
|
42384
|
+
[{ id: "quotes_month" }, { id: "quotes_won" }], "quote_recovery",
|
|
42385
|
+
function () { return (raw("quotes_month") - raw("quotes_won")) * a("quote_recovery") * v; },
|
|
42386
|
+
"Follow-up that runs itself");
|
|
42387
|
+
}
|
|
42388
|
+
|
|
42389
|
+
add("noshow", "No-shows nobody rebooks",
|
|
42390
|
+
[{ id: "booked_month" }, { id: "show_rate", share: true }], "noshow_recovery",
|
|
42391
|
+
function () { return raw("booked_month") * (1 - share("show_rate")) * a("noshow_recovery") * c * v; },
|
|
42392
|
+
"Reminders and rebooking");
|
|
42393
|
+
|
|
42394
|
+
var monthly = F.reduce(function (s, f) { return s + f.monthly; }, 0);
|
|
42395
|
+
var stop = monthly * a("recovery");
|
|
42396
|
+
|
|
42397
|
+
var oneoff = null;
|
|
42398
|
+
if (core && raw("dormant_count") !== null && a("reactivation") > 0) {
|
|
42399
|
+
oneoff = raw("dormant_count") * a("reactivation") * c * v;
|
|
42400
|
+
}
|
|
42401
|
+
|
|
42402
|
+
var hrs = [];
|
|
42403
|
+
HOUR_KEYS.forEach(function (p) { var n = raw(p[0]); if (n !== null && n >= 0) hrs.push({ id: p[0], t: p[1], h: n }); });
|
|
42404
|
+
var hrsWk = hrs.reduce(function (s, x) { return s + x.h; }, 0);
|
|
42405
|
+
|
|
42406
|
+
var verdict, line;
|
|
42407
|
+
if (!core) {
|
|
42408
|
+
verdict = "insufficient";
|
|
42409
|
+
// Names the gap. "The figures are not known" made a one-number gap sound
|
|
42410
|
+
// like the whole interview had failed, and gave nobody anything to go and do.
|
|
42411
|
+
line = coreGaps.length === 1
|
|
42412
|
+
? "We could not put a number on anything yet. Every finding is multiplied by one figure we do not have, and that is the only thing standing in the way."
|
|
42413
|
+
: "We could not put a number on anything yet, because the two figures everything else is priced against are not known.";
|
|
42414
|
+
} else if (!F.length) {
|
|
42415
|
+
verdict = "insufficient";
|
|
42416
|
+
line = "We could not price a single leak from the numbers available.";
|
|
42417
|
+
} else if (monthly < v) {
|
|
42418
|
+
// Thin is measured against THEIR average job: everything found together is
|
|
42419
|
+
// worth less than one new customer a month. Never a benchmark, never a fee.
|
|
42420
|
+
verdict = "thin";
|
|
42421
|
+
line = "Everything we found together is worth less than one new customer a month to you. That is worth saying plainly rather than dressing up.";
|
|
42422
|
+
} else {
|
|
42423
|
+
verdict = "findings";
|
|
42424
|
+
line = "We found " + F.length + " thing" + (F.length === 1 ? "" : "s") + " worth fixing.";
|
|
42425
|
+
}
|
|
42426
|
+
|
|
42427
|
+
return {
|
|
42428
|
+
F: F, O: O, core: core,
|
|
42429
|
+
monthly: monthly, annual: monthly * 12,
|
|
42430
|
+
stop: stop, annualStop: stop * 12,
|
|
42431
|
+
oneoff: oneoff, hrs: hrs, hrsWk: hrsWk,
|
|
42432
|
+
verdict: verdict, line: line
|
|
42433
|
+
};
|
|
42434
|
+
}
|
|
42435
|
+
|
|
42436
|
+
/**
|
|
42437
|
+
* The report \u2014 the document the agency hands their prospect.
|
|
42438
|
+
*
|
|
42439
|
+
* This is the only thing here a stranger ever reads, and it goes out on the
|
|
42440
|
+
* agency's letterhead, not ours. Three rules are enforced by construction:
|
|
42441
|
+
*
|
|
42442
|
+
* 1. NOTHING OF OURS APPEARS. No product name, no tool name, no "generated by".
|
|
42443
|
+
* Paul's client is buying from Paul.
|
|
42444
|
+
* 2. EVERY FIGURE CARRIES ITS SOURCE. A number the owner gave, a number the
|
|
42445
|
+
* operator assumed, and a number nobody knew are visibly different things,
|
|
42446
|
+
* and the assumption prints beside the figure it moved rather than in a
|
|
42447
|
+
* footnote nobody reads.
|
|
42448
|
+
* 3. EVERY PIECE OF TEXT IS ESCAPED. The report is published to a real domain
|
|
42449
|
+
* and is full of words a prospect typed into a form. Escaping is a
|
|
42450
|
+
* correctness requirement here, not hygiene.
|
|
42451
|
+
*
|
|
42452
|
+
* Pure: takes data, returns an HTML string. No DOM, no network, no clock beyond
|
|
42453
|
+
* the date it is handed. That is what makes it testable, and it is inlined into
|
|
42454
|
+
* the assessment page by build-app.mjs like the engine.
|
|
42455
|
+
*/
|
|
42456
|
+
|
|
42457
|
+
function esc(s) {
|
|
42458
|
+
return String(s === undefined || s === null ? "" : s)
|
|
42459
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
42460
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
42461
|
+
}
|
|
42462
|
+
|
|
42463
|
+
/** Does this value look like a figure rather than a sentence? Fallback only. */
|
|
42464
|
+
function isFigure(v) {
|
|
42465
|
+
return /^[\\s$\xA3\u20AC]*[-+]?[\\d][\\d.,]*\\s*(%|hrs?|hours?|mins?|minutes?|days?)?\\s*$/i.test(String(v === undefined || v === null ? "" : v));
|
|
42466
|
+
}
|
|
42467
|
+
|
|
42468
|
+
function money(n) {
|
|
42469
|
+
return "$" + Math.round(Number(n) || 0).toLocaleString("en-US");
|
|
42470
|
+
}
|
|
42471
|
+
|
|
42472
|
+
/** A colour the operator typed is going straight into a style attribute. */
|
|
42473
|
+
function safeColour(c) {
|
|
42474
|
+
return /^#[0-9a-fA-F]{3,8}$/.test(String(c || "")) ? String(c) : "#1F4E79";
|
|
42475
|
+
}
|
|
42476
|
+
|
|
42477
|
+
/**
|
|
42478
|
+
* agency { name, accent?, contact? } \u2014 from Layer 1. Never invented.
|
|
42479
|
+
* services[] { name, price?, covers:[findingId] } \u2014 THEIR menu at THEIR prices.
|
|
42480
|
+
* A finding with no matching service still appears; it just has no price beside
|
|
42481
|
+
* it, because recommending something the agency does not sell is worse than a
|
|
42482
|
+
* gap in a table.
|
|
42483
|
+
*/
|
|
42484
|
+
function renderReport(opts) {
|
|
42485
|
+
var state = opts.state || {};
|
|
42486
|
+
var r = opts.computed || {};
|
|
42487
|
+
var agency = opts.agency || {};
|
|
42488
|
+
var services = Array.isArray(opts.services) ? opts.services : [];
|
|
42489
|
+
var assumptionLabels = opts.assumptionLabels || {};
|
|
42490
|
+
var date = opts.date || "";
|
|
42491
|
+
var accent = safeColour(agency.accent);
|
|
42492
|
+
|
|
42493
|
+
var biz = esc(state.biz || "your business");
|
|
42494
|
+
var F = Array.isArray(r.F) ? r.F : [];
|
|
42495
|
+
var O = Array.isArray(r.O) ? r.O : [];
|
|
42496
|
+
var hrs = Array.isArray(r.hrs) ? r.hrs : [];
|
|
42497
|
+
|
|
42498
|
+
var out = [];
|
|
42499
|
+
out.push('<!doctype html><html lang="en"><head><meta charset="utf-8">');
|
|
42500
|
+
out.push('<meta name="viewport" content="width=device-width,initial-scale=1">');
|
|
42501
|
+
// This document carries another business's revenue, close rate and list size.
|
|
42502
|
+
// Wherever it ends up, it must never be indexed.
|
|
42503
|
+
out.push('<meta name="robots" content="noindex,nofollow,noarchive">');
|
|
42504
|
+
out.push("<title>Where " + biz + " is leaking</title>");
|
|
42505
|
+
out.push("<style>" + css(accent) + "</style></head><body><div class=wrap>");
|
|
42506
|
+
|
|
42507
|
+
// \u2500\u2500 masthead: theirs, not ours \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
42508
|
+
out.push("<header>");
|
|
42509
|
+
if (agency.name) out.push('<p class="by">Prepared by ' + esc(agency.name) + "</p>");
|
|
42510
|
+
out.push("<h1>Where " + biz + " is leaking</h1>");
|
|
42511
|
+
var who = state.client && state.client.name ? esc(state.client.name) + (state.client.role ? ", " + esc(state.client.role) : "") : "";
|
|
42512
|
+
if (who) out.push('<p class="for">Prepared for ' + who + "</p>");
|
|
42513
|
+
if (date) out.push('<p class="when">' + esc(date) + "</p>");
|
|
42514
|
+
// The agency's own method and promise, from Layer 1's Agency OS inputs. Their
|
|
42515
|
+
// words, never ours; absent when they have not written them.
|
|
42516
|
+
if (agency.method || agency.promise) {
|
|
42517
|
+
out.push('<p class="about">' + [agency.method, agency.promise].filter(Boolean).map(esc).join(" ") + "</p>");
|
|
42518
|
+
}
|
|
42519
|
+
out.push("</header>");
|
|
42520
|
+
|
|
42521
|
+
// \u2500\u2500 the three verdicts are three different documents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
42522
|
+
if (r.verdict === "insufficient") {
|
|
42523
|
+
out.push('<div class="verdict stop"><b>We could not put a number on this yet</b>'
|
|
42524
|
+
+ esc(r.line || "") + "</div>");
|
|
42525
|
+
out.push(measureFirst(O));
|
|
42526
|
+
out.push(footer(agency));
|
|
42527
|
+
return out.join("\\n") + "</div></body></html>";
|
|
42528
|
+
}
|
|
42529
|
+
|
|
42530
|
+
if (r.verdict === "thin") {
|
|
42531
|
+
out.push('<div class="verdict warn"><b>Honestly, there is not much here</b>'
|
|
42532
|
+
+ esc(r.line || "") + "</div>");
|
|
42533
|
+
}
|
|
42534
|
+
|
|
42535
|
+
// \u2500\u2500 headline numbers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
42536
|
+
out.push('<div class="hgrid">');
|
|
42537
|
+
if (r.hrsWk > 0) out.push(head(r.hrsWk.toFixed(1), "hours a week going into work a system should do"));
|
|
42538
|
+
if (r.annual > 0) out.push(head(money(r.annual), "a year leaking, on your own numbers"));
|
|
42539
|
+
if (r.annualStop > 0) out.push(head(money(r.annualStop), "a year we expect to actually stop"));
|
|
42540
|
+
out.push("</div>");
|
|
42541
|
+
|
|
42542
|
+
// \u2500\u2500 what you told us \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
42543
|
+
if (opts.answered && opts.answered.length) {
|
|
42544
|
+
out.push("<h2>What we started from, in your words</h2><table class=\\"qa\\">");
|
|
42545
|
+
for (var a = 0; a < opts.answered.length; a++) {
|
|
42546
|
+
var q = opts.answered[a];
|
|
42547
|
+
// \`.num\` is right-aligned AND nowrap, which is right for a figure and
|
|
42548
|
+
// ruinous for a sentence: one typed answer like "I get to them at night,
|
|
42549
|
+
// usually" held the value column open and crushed the question column to
|
|
42550
|
+
// 113px of a 926px page, wrapping every question over five lines. The
|
|
42551
|
+
// unit says which kind of answer this is; the shape of the value is the
|
|
42552
|
+
// fallback for a caller that does not send one.
|
|
42553
|
+
var numeric = q.unit !== undefined ? q.unit !== "text" : isFigure(q.value);
|
|
42554
|
+
out.push("<tr><td>" + esc(q.ask) + '</td><td class="' + (numeric ? "num" : "said") + '">'
|
|
42555
|
+
+ (q.unknown ? '<span class="unk">you did not know</span>' : esc(q.value)) + "</td></tr>");
|
|
42556
|
+
}
|
|
42557
|
+
out.push("</table>");
|
|
42558
|
+
}
|
|
42559
|
+
|
|
42560
|
+
// \u2500\u2500 the findings \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
42561
|
+
if (F.length) {
|
|
42562
|
+
out.push("<h2>Where the money goes</h2><table>");
|
|
42563
|
+
out.push('<tr><th>What is happening</th><th class="num">A month</th></tr>');
|
|
42564
|
+
for (var i = 0; i < F.length; i++) {
|
|
42565
|
+
var f = F[i];
|
|
42566
|
+
var note = f.assumption
|
|
42567
|
+
? '<span class="assume">' + esc(assumptionLabels[f.assumption] || "This is your assumption, not a measurement.") + "</span>"
|
|
42568
|
+
: "";
|
|
42569
|
+
out.push("<tr><td><strong>" + esc(f.title) + "</strong>" + note + '</td><td class="num">'
|
|
42570
|
+
+ money(f.monthly) + "</td></tr>");
|
|
42571
|
+
}
|
|
42572
|
+
out.push('<tr class="tot"><td><strong>Leaking, every month</strong></td><td class="num"><strong>'
|
|
42573
|
+
+ money(r.monthly) + "</strong></td></tr>");
|
|
42574
|
+
if (r.stop > 0) {
|
|
42575
|
+
out.push('<tr><td><strong>What we expect to actually stop</strong><span class="assume">'
|
|
42576
|
+
+ esc(assumptionLabels.recovery || "No system catches everything. This is your figure.")
|
|
42577
|
+
+ '</span></td><td class="num"><strong>' + money(r.stop) + "</strong></td></tr>");
|
|
42578
|
+
}
|
|
42579
|
+
if (r.oneoff !== null && r.oneoff !== undefined && r.oneoff > 0) {
|
|
42580
|
+
out.push('<tr><td>Your dormant list, worked once<span class="assume">'
|
|
42581
|
+
+ esc(assumptionLabels.reactivation || "Your assumption, not a measurement.")
|
|
42582
|
+
+ " This is a one-off and is left out of every annual figure above."
|
|
42583
|
+
+ '</span></td><td class="num">' + money(r.oneoff) + "</td></tr>");
|
|
42584
|
+
}
|
|
42585
|
+
out.push("</table>");
|
|
42586
|
+
}
|
|
42587
|
+
|
|
42588
|
+
// \u2500\u2500 named, deliberately not priced \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
42589
|
+
var named = O.filter(function (o) { return o.reason || o.declined; });
|
|
42590
|
+
if (named.length) {
|
|
42591
|
+
out.push("<h2>Named, and deliberately not priced</h2><ul>");
|
|
42592
|
+
for (var j = 0; j < named.length; j++) {
|
|
42593
|
+
var o = named[j];
|
|
42594
|
+
out.push("<li><strong>" + esc(o.title) + "</strong> — "
|
|
42595
|
+
+ esc(o.declined ? "priced only on an assumption you chose not to make." : o.reason) + "</li>");
|
|
42596
|
+
}
|
|
42597
|
+
out.push("</ul><p class=small>We can see these matter and we are not going to invent a number for them.</p>");
|
|
42598
|
+
}
|
|
42599
|
+
|
|
42600
|
+
// \u2500\u2500 hours \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
42601
|
+
if (hrs.length) {
|
|
42602
|
+
out.push("<h2>Where the time goes</h2><table>");
|
|
42603
|
+
for (var h = 0; h < hrs.length; h++) {
|
|
42604
|
+
out.push("<tr><td>" + esc(hrs[h].t) + '</td><td class="num">' + Number(hrs[h].h).toFixed(1) + " hrs</td></tr>");
|
|
42605
|
+
}
|
|
42606
|
+
out.push('<tr class="tot"><td><strong>A week</strong></td><td class="num"><strong>'
|
|
42607
|
+
+ Number(r.hrsWk).toFixed(1) + "</strong></td></tr></table>");
|
|
42608
|
+
}
|
|
42609
|
+
|
|
42610
|
+
// \u2500\u2500 the plan, from THEIR menu \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
42611
|
+
if (F.length) {
|
|
42612
|
+
out.push("<h2>What we would do, in order</h2><table>");
|
|
42613
|
+
out.push('<tr><th>Fixes</th><th>What it is</th></tr>');
|
|
42614
|
+
for (var k = 0; k < F.length; k++) {
|
|
42615
|
+
var fx = F[k];
|
|
42616
|
+
var svc = services.find(function (s) {
|
|
42617
|
+
return Array.isArray(s.covers) && s.covers.indexOf(fx.id) !== -1;
|
|
42618
|
+
});
|
|
42619
|
+
out.push("<tr><td>" + esc(fx.title) + "</td><td>" + esc(svc ? svc.name : fx.fix || "") + "</td></tr>");
|
|
42620
|
+
}
|
|
42621
|
+
out.push("</table>");
|
|
42622
|
+
if (!services.length) {
|
|
42623
|
+
out.push('<p class="small">Service names above are descriptions, not a quote. Add your own offers so this reads in your language and at your prices.</p>');
|
|
42624
|
+
}
|
|
42625
|
+
}
|
|
42626
|
+
|
|
42627
|
+
// \u2500\u2500 investment, only when they have priced it \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
42628
|
+
var priced = services.filter(function (s) { return typeof s.price === "number" && s.price > 0; });
|
|
42629
|
+
if (priced.length && r.annualStop > 0) {
|
|
42630
|
+
var total = priced.reduce(function (s, x) { return s + x.price; }, 0);
|
|
42631
|
+
out.push("<h2>The investment</h2><table>");
|
|
42632
|
+
for (var p = 0; p < priced.length; p++) {
|
|
42633
|
+
out.push("<tr><td>" + esc(priced[p].name) + '</td><td class="num">' + money(priced[p].price) + "</td></tr>");
|
|
42634
|
+
}
|
|
42635
|
+
out.push('<tr class="tot"><td><strong>Total</strong></td><td class="num"><strong>' + money(total) + "</strong></td></tr>");
|
|
42636
|
+
out.push('<tr><td>Against what we expect to stop, first year</td><td class="num">' + money(r.annualStop) + "</td></tr>");
|
|
42637
|
+
if (total > 0) {
|
|
42638
|
+
var ratio = r.annualStop / total;
|
|
42639
|
+
out.push('<tr class="tot"><td><strong>You are spending $1 to stop $' + ratio.toFixed(2)
|
|
42640
|
+
+ "</strong></td><td class=\\"num\\"><strong>" + ratio.toFixed(1) + "×</strong></td></tr>");
|
|
42641
|
+
}
|
|
42642
|
+
out.push("</table>");
|
|
42643
|
+
}
|
|
42644
|
+
|
|
42645
|
+
out.push(sourceLine(state, r, assumptionLabels));
|
|
42646
|
+
out.push(footer(agency));
|
|
42647
|
+
return out.join("\\n") + "</div></body></html>";
|
|
42648
|
+
}
|
|
42649
|
+
|
|
42650
|
+
function head(n, label) {
|
|
42651
|
+
return '<div><span class="hn">' + esc(n) + '</span><span class="hl">' + esc(label) + "</span></div>";
|
|
42652
|
+
}
|
|
42653
|
+
|
|
42654
|
+
/**
|
|
42655
|
+
* The list of things to go and count.
|
|
42656
|
+
*
|
|
42657
|
+
* Reads the labels the ENGINE attached beside the ids, never the ids. This
|
|
42658
|
+
* printed "close_rate" at a prospect until 2026-08-30 \u2014 machine text in a
|
|
42659
|
+
* document going out on the operator's letterhead. An id with no label is
|
|
42660
|
+
* dropped rather than printed, because a stranger reading a field name is a
|
|
42661
|
+
* worse failure than a short list.
|
|
42662
|
+
*/
|
|
42663
|
+
function measureFirst(O) {
|
|
42664
|
+
var seen = [];
|
|
42665
|
+
for (var i = 0; i < O.length; i++) {
|
|
42666
|
+
var m = O[i].missingLabels || [];
|
|
42667
|
+
for (var j = 0; j < m.length; j++) if (seen.indexOf(m[j]) === -1) seen.push(m[j]);
|
|
42668
|
+
}
|
|
42669
|
+
if (!seen.length) return "";
|
|
42670
|
+
return "<h2>What to measure first</h2><p>These are the numbers that would let us price the rest. "
|
|
42671
|
+
+ "None of them need a system — a week of writing them down is enough.</p><ul><li>"
|
|
42672
|
+
+ seen.map(esc).join("</li><li>") + "</li></ul>";
|
|
42673
|
+
}
|
|
42674
|
+
|
|
42675
|
+
/** The sentence the whole document rests on. */
|
|
42676
|
+
function sourceLine(state, r, labels) {
|
|
42677
|
+
var used = [];
|
|
42678
|
+
var F = r.F || [];
|
|
42679
|
+
for (var i = 0; i < F.length; i++) if (F[i].assumption && used.indexOf(F[i].assumption) === -1) used.push(F[i].assumption);
|
|
42680
|
+
var n = used.length + (r.stop > 0 ? 1 : 0);
|
|
42681
|
+
return '<p class="src">Every figure here came from what you told us'
|
|
42682
|
+
+ (state.trade ? ", or from your own " + esc(state.trade) + " business" : "")
|
|
42683
|
+
+ ". " + (n > 0
|
|
42684
|
+
? n + " of them " + (n === 1 ? "is an estimate" : "are estimates") + " we chose together, each marked where it is used. "
|
|
42685
|
+
: "")
|
|
42686
|
+
+ "Nothing here is an industry average applied to your business.</p>";
|
|
42687
|
+
}
|
|
42688
|
+
|
|
42689
|
+
function footer(agency) {
|
|
42690
|
+
if (!agency.name) return "";
|
|
42691
|
+
return '<footer>' + esc(agency.name) + (agency.contact ? " · " + esc(agency.contact) : "")
|
|
42692
|
+
+ (agency.guarantee ? '<br><span class="guarantee">' + esc(agency.guarantee) + "</span>" : "") + "</footer>";
|
|
42693
|
+
}
|
|
42694
|
+
|
|
42695
|
+
function css(accent) {
|
|
42696
|
+
return [
|
|
42697
|
+
":root{--a:" + accent + ";--ink:#14171F;--mut:#4A5163;--rule:#D8DDE5;--pan:#F4F6F8}",
|
|
42698
|
+
"*{box-sizing:border-box}",
|
|
42699
|
+
"body{margin:0;background:#fff;color:var(--ink);font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}",
|
|
42700
|
+
".wrap{max-width:44rem;margin:0 auto;padding:2rem 1.1rem 4rem}",
|
|
42701
|
+
"header{border-bottom:3px solid var(--a);padding-bottom:.9rem;margin-bottom:1.4rem}",
|
|
42702
|
+
".by{font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--a);margin:0 0 .4rem;font-weight:600}",
|
|
42703
|
+
"h1{font-size:1.9rem;line-height:1.15;margin:0;font-weight:700}",
|
|
42704
|
+
".when{color:var(--mut);font-size:.82rem;margin:.4rem 0 0}",
|
|
42705
|
+
".for{color:var(--ink);font-size:.95rem;margin:.5rem 0 0}",
|
|
42706
|
+
".about{color:var(--mut);font-size:.95rem;margin:.6rem 0 0;max-width:38rem}",
|
|
42707
|
+
".guarantee{color:var(--mut);font-size:.85rem}",
|
|
42708
|
+
"h2{font-size:1.15rem;margin:2rem 0 .5rem;font-weight:700}",
|
|
42709
|
+
"table{border-collapse:collapse;width:100%;margin:.6rem 0;font-size:.94rem}",
|
|
42710
|
+
"th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--rule);vertical-align:top}",
|
|
42711
|
+
"th{font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);border-bottom:2px solid var(--rule)}",
|
|
42712
|
+
".num{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}",
|
|
42713
|
+
// A typed sentence wraps and stays left. It is prose, not a figure.
|
|
42714
|
+
".said{text-align:left;white-space:normal}",
|
|
42715
|
+
// The question is the column that must stay readable, so it gets the space
|
|
42716
|
+
// and the answer takes what it needs.
|
|
42717
|
+
"table.qa td:first-child{width:62%}",
|
|
42718
|
+
"tr.tot td{background:var(--pan)}",
|
|
42719
|
+
".assume{display:block;font-size:.76rem;color:var(--mut);font-style:italic;margin-top:.2rem}",
|
|
42720
|
+
".unk{color:var(--mut);font-style:italic}",
|
|
42721
|
+
".hgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr));gap:1rem;margin:1.2rem 0}",
|
|
42722
|
+
".hn{display:block;font-size:1.7rem;font-weight:700;color:var(--a);line-height:1.1}",
|
|
42723
|
+
".hl{display:block;font-size:.78rem;color:var(--mut);margin-top:.15rem}",
|
|
42724
|
+
".verdict{border-left:4px solid var(--a);background:var(--pan);padding:.75rem .9rem;margin:1rem 0;font-size:.94rem}",
|
|
42725
|
+
".verdict.warn{border-left-color:#8A5300}.verdict.stop{border-left-color:#9A3324}",
|
|
42726
|
+
".verdict b{display:block;margin-bottom:.15rem}",
|
|
42727
|
+
".src{margin-top:1.6rem;padding-top:.8rem;border-top:1px solid var(--rule);font-size:.82rem;color:var(--mut)}",
|
|
42728
|
+
".small{font-size:.82rem;color:var(--mut)}",
|
|
42729
|
+
"ul{padding-left:1.1rem}li{margin-bottom:.3rem}",
|
|
42730
|
+
"footer{margin-top:2rem;padding-top:.8rem;border-top:3px solid var(--a);font-size:.8rem;color:var(--mut)}",
|
|
42731
|
+
"@media print{.wrap{max-width:none}body{font-size:11pt}}",
|
|
42732
|
+
].join("");
|
|
42733
|
+
}
|
|
42734
|
+
|
|
42735
|
+
/* \u2500\u2500 state (persistence only; all maths lives in engine.js) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
42736
|
+
var K="cmdos-assessment-v1";
|
|
42737
|
+
var S={biz:"",trade:"",client:{},saveTo:"",ans:{},skip:{},asm:{},tab:"ask",area:"client"};
|
|
42738
|
+
ASSUMPTIONS.forEach(function(a){S.asm[a[0]]=a[2]});
|
|
42739
|
+
try{var raw=localStorage.getItem(K);if(raw)S=Object.assign(S,JSON.parse(raw))}catch(e){}
|
|
42740
|
+
if(!S.client)S.client={};if(S.saveTo===undefined)S.saveTo="";
|
|
42741
|
+
function save(){try{localStorage.setItem(K,JSON.stringify(S))}catch(e){}}
|
|
42742
|
+
function txt(id){return S.skip[id]?null:(S.ans[id]||null)}
|
|
42743
|
+
function money(n){return "$"+Math.round(n).toLocaleString()}
|
|
42744
|
+
function R(){return compute(S.ans,S.skip,S.asm)}
|
|
42745
|
+
/* \u2500\u2500 render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
42746
|
+
function progress(){var d=0;Q.forEach(function(q){if(S.skip[q[0]]||(S.ans[q[0]]!==undefined&&S.ans[q[0]]!==""))d++});return d}
|
|
42747
|
+
function areaCount(a){var t=0,d=0;Q.forEach(function(q){if(q[1]===a){t++;if(S.skip[q[0]]||(S.ans[q[0]]!==undefined&&S.ans[q[0]]!==""))d++}});return d+"/"+t}
|
|
42748
|
+
|
|
42749
|
+
function head(){
|
|
42750
|
+
var r=R();
|
|
42751
|
+
document.getElementById("lProg").textContent=progress()+"/"+Q.length;
|
|
42752
|
+
document.getElementById("lHrs").textContent=r.hrsWk?r.hrsWk.toFixed(1):"\u2014";
|
|
42753
|
+
document.getElementById("lMon").textContent=r.monthly?money(r.monthly):"\u2014";
|
|
42754
|
+
document.getElementById("bizname").textContent=S.biz||"New assessment";
|
|
42755
|
+
document.getElementById("bizmeta").textContent=[S.trade,(S.client&&S.client.name)?"with "+S.client.name:""].filter(Boolean).join(" \xB7 ");
|
|
42756
|
+
}
|
|
42757
|
+
|
|
42758
|
+
/**
|
|
42759
|
+
* The banner that stops an operator answering 46 questions for nothing.
|
|
42760
|
+
*
|
|
42761
|
+
* Every priced finding multiplies by the average job value AND the close rate,
|
|
42762
|
+
* so one blank there produces a report that can price nothing at all. It used to
|
|
42763
|
+
* say so only at the very end, on the report itself. Now it says so on every
|
|
42764
|
+
* tab, names which number is missing in words, and links straight to it.
|
|
42765
|
+
*/
|
|
42766
|
+
function coreWarnHtml(){
|
|
42767
|
+
var gaps=coreMissing(S.ans,S.skip);
|
|
42768
|
+
if(!gaps.length)return "";
|
|
42769
|
+
var names=measureLabels(gaps);
|
|
42770
|
+
var one=gaps.length===1;
|
|
42771
|
+
return '<div class="note stop"><b>'+(one?"One number is missing, and nothing can be priced without it"
|
|
42772
|
+
:"Two numbers are missing, and nothing can be priced without them")+'</b>'
|
|
42773
|
+
+'Every finding in this assessment is multiplied by '+(one?"it":"them")+', so the report will say it could not put a number on anything until '+(one?"it is":"they are")+' filled in.'
|
|
42774
|
+
+'<ul style="margin:.4rem 0 .3rem;padding-left:1.1rem">'
|
|
42775
|
+
+names.map(function(n){return "<li>"+n+"</li>"}).join("")+'</ul>'
|
|
42776
|
+
+'<button class="mini" onclick="goCore()">Take me to '+(one?"it":"them")+'</button></div>';
|
|
42777
|
+
}
|
|
42778
|
+
/**
|
|
42779
|
+
* The report is addressed to somebody, and renders nothing without a name \u2014 a
|
|
42780
|
+
* third blocker that announced itself only on the Report tab, after all 46
|
|
42781
|
+
* questions were done. Same failure as the two numbers (command-center found it
|
|
42782
|
+
* in the browser pass), so it gets the same treatment rather than a second
|
|
42783
|
+
* style of warning living somewhere else.
|
|
42784
|
+
*/
|
|
42785
|
+
function bizWarnHtml(){
|
|
42786
|
+
if(S.biz&&S.biz.trim())return "";
|
|
42787
|
+
return '<div class="note warn"><b>No business name yet</b>'
|
|
42788
|
+
+'The report below is addressed to “your business” until you give it one. It renders either way, and that is the problem: it looks finished and is not sendable.'
|
|
42789
|
+
+' <button class="mini" onclick="goBiz()">Take me to it</button></div>';
|
|
42790
|
+
}
|
|
42791
|
+
function goBiz(){S.tab="ask";S.area="client";save();draw();
|
|
42792
|
+
var el=document.getElementById("f_biz");
|
|
42793
|
+
if(el){el.scrollIntoView({behavior:"smooth",block:"center"});el.focus()}}
|
|
42794
|
+
/** Every standing warning, in one place, so all three tabs show the same set. */
|
|
42795
|
+
function warnsHtml(){return coreWarnHtml()+bizWarnHtml()}
|
|
42796
|
+
|
|
42797
|
+
function goCore(){S.tab="ask";S.area="money";save();draw();
|
|
42798
|
+
var g=coreMissing(S.ans,S.skip);
|
|
42799
|
+
if(g.length){var el=document.getElementById("q_"+g[0]);
|
|
42800
|
+
if(el){el.scrollIntoView({behavior:"smooth",block:"center"});var i=el.querySelector("input");if(i)i.focus()}}}
|
|
42801
|
+
|
|
42802
|
+
/** How much of the client step is filled: the two the report needs plus the contact. */
|
|
42803
|
+
function clientCount(){var c=S.client||{};var f=[S.biz,S.trade,c.name,c.email||c.phone].filter(function(v){return v&&String(v).trim()}).length;return f+"/4"}
|
|
42804
|
+
function railHtml(){
|
|
42805
|
+
if(S.tab!=="ask")return "";
|
|
42806
|
+
return '<button class="'+(S.area==="client"?"on":"")+'" onclick="goArea(\\'client\\')">The client<span class="c">'+clientCount()+'</span></button>'
|
|
42807
|
+
+AREAS.map(function(a){return '<button class="'+(S.area===a[0]?"on":"")+'" onclick="goArea(\\''+a[0]+'\\')">'
|
|
42808
|
+
+a[1]+'<span class="c">'+areaCount(a[0])+'</span></button>'}).join("")}
|
|
42809
|
+
|
|
42810
|
+
function inputFor(q){
|
|
42811
|
+
var id=q[0],u=q[4],val=S.ans[id]===undefined?"":S.ans[id],sk=!!S.skip[id];
|
|
42812
|
+
var unit={currency:"$",rate:"% or 0-1",count:"number",minutes:"minutes",hours:"hrs/week",text:""}[u]||"";
|
|
42813
|
+
var el= u==="text"
|
|
42814
|
+
? '<textarea class="a" oninput="setA(\\''+id+'\\',this.value)" '+(sk?"disabled":"")+'>'+String(val).replace(/</g,"<")+'</textarea>'
|
|
42815
|
+
: '<input class="a" inputmode="decimal" value="'+String(val).replace(/"/g,""")+'" oninput="setA(\\''+id+'\\',this.value)" '+(sk?"disabled":"")+'>';
|
|
42816
|
+
return '<div class="row">'+el+'<span class="unit">'+unit+'</span>'
|
|
42817
|
+
+'<button class="mini '+(sk?"on":"")+'" onclick="skip(\\''+id+'\\')">'+(sk?"marked don\\'t know":"don\\'t know")+'</button>'
|
|
42818
|
+
+'<button class="mini" onclick="why(\\''+id+'\\')">why</button></div>'}
|
|
42819
|
+
|
|
42820
|
+
/**
|
|
42821
|
+
* The first step: who this is for. Most operators use the assessment as the
|
|
42822
|
+
* sales pitch and the proposal, so the client and the business come before any
|
|
42823
|
+
* number (Jerry, 2026-09-05). It also says which account on the board the
|
|
42824
|
+
* assessment belongs to, so Save to GoHighLevel knows where to put it.
|
|
42825
|
+
*/
|
|
42826
|
+
function escH(s){return String(s==null?"":s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}
|
|
42827
|
+
function cf(k,ph,type){var c=S.client||{};return '<input class="a" type="'+(type||"text")+'" placeholder="'+escH(ph)+'" value="'+escH(c[k]||"")+'" oninput="setClient(\\''+k+'\\',this.value)">'}
|
|
42828
|
+
function setClient(k,v){if(!S.client)S.client={};S.client[k]=v;save();head()}
|
|
42829
|
+
function setSaveTo(v){S.saveTo=v;save();try{localStorage.setItem(K+":saveTo",v)}catch(e){}}
|
|
42830
|
+
function accountSelectHtml(){
|
|
42831
|
+
var list=window.COMMAND_OS_CLIENTS;
|
|
42832
|
+
if(!list||!list.length)return '<p class="probe">Open this page from Command OS to choose the account it is saved into.</p>';
|
|
42833
|
+
var cur=S.saveTo;try{if(!cur)cur=localStorage.getItem(K+":saveTo")||""}catch(e){}
|
|
42834
|
+
return '<div class="row"><select class="a" onchange="setSaveTo(this.value)"><option value="">Which account does this belong to?</option>'
|
|
42835
|
+
+list.map(function(c){return '<option value="'+escH(c.locationId)+'"'+(c.locationId===cur?" selected":"")+'>'+escH(c.name)+'</option>'}).join("")
|
|
42836
|
+
+'</select></div><p class="why" style="display:block">Usually your own agency account: the prospect becomes a contact there, the answers a note on that contact, and the deal a card on your Assessments board. A client account only once they are one.</p>';
|
|
42837
|
+
}
|
|
42838
|
+
function clientHtml(){
|
|
42839
|
+
return warnsHtml()+'<h2>The client</h2><p class="intro">“Before any number: who this is for, and where it lives.”</p>'
|
|
42840
|
+
+'<div class="q done" id="q_client"><p class="ask">The business</p>'
|
|
42841
|
+
+'<div class="row"><input class="a" id="f_biz" placeholder="Business name" value="'+escH(S.biz||"")+'" oninput="S.biz=this.value;save();head()">'
|
|
42842
|
+
+'<input class="a" placeholder="Trade (med spa, dental, home care\u2026)" value="'+escH(S.trade||"")+'" oninput="S.trade=this.value;save();head()"></div>'
|
|
42843
|
+
+'<div class="row">'+cf("website","Website","url")+cf("city","City, state")+'</div></div>'
|
|
42844
|
+
+'<div class="q done"><p class="ask">The person you are talking to</p>'
|
|
42845
|
+
+'<div class="row">'+cf("name","Their name")+cf("role","Their role (owner, office manager\u2026)")+'</div>'
|
|
42846
|
+
+'<div class="row">'+cf("email","Email","email")+cf("phone","Phone","tel")+'</div>'
|
|
42847
|
+
+'<p class="why" style="display:block">An email or a phone number is what lets the same business be found again next time instead of a second contact being created.</p></div>'
|
|
42848
|
+
+'<div class="q done"><p class="ask">Where this assessment is saved</p>'+accountSelectHtml()+'</div>'
|
|
42849
|
+
+'<div class="btnrow"><button class="btn" onclick="goArea(\\''+AREAS[0][0]+'\\')">Next: '+AREAS[0][1]+'</button></div>'}
|
|
42850
|
+
function askHtml(){
|
|
42851
|
+
if(S.area==="client")return clientHtml();
|
|
42852
|
+
var a=AREAS.filter(function(x){return x[0]===S.area})[0];
|
|
42853
|
+
var qs=Q.filter(function(q){return q[1]===S.area});
|
|
42854
|
+
return warnsHtml()+'<h2>'+a[1]+'</h2><p class="intro">“'+a[2]+'”</p>'
|
|
42855
|
+
+qs.map(function(q){
|
|
42856
|
+
var done=S.ans[q[0]]!==undefined&&S.ans[q[0]]!=="",sk=!!S.skip[q[0]];
|
|
42857
|
+
return '<div class="q '+(sk?"skip":done?"done":"")+'" id="q_'+q[0]+'">'
|
|
42858
|
+
+'<p class="ask">'+q[2]+'</p>'
|
|
42859
|
+
+(q[3]?'<p class="probe">If they go vague: “'+q[3]+'”</p>':'')
|
|
42860
|
+
+inputFor(q)+'<p class="why">'+q[5]+'</p></div>'}).join("")
|
|
42861
|
+
+'<div class="btnrow"><button class="btn" onclick="nextArea()">Next area</button>'
|
|
42862
|
+
+'<button class="btn ghost" onclick="tab(\\'find\\')">See findings</button></div>'}
|
|
42863
|
+
|
|
42864
|
+
function setHtml(){
|
|
42865
|
+
return '<h2>Your assumptions</h2><p class="intro">Every one of these prints in the report next to the figure it moved. Set one to zero and its finding disappears rather than falling back to an industry number.</p><div class="card">'
|
|
42866
|
+
+ASSUMPTIONS.map(function(a){
|
|
42867
|
+
return '<div class="assume"><div><b>'+a[1]+'</b><br><span class="unit">'+a[3]+'</span></div>'
|
|
42868
|
+
+'<input class="a" style="max-width:5rem" value="'+(S.asm[a[0]])+'" oninput="setAsm(\\''+a[0]+'\\',this.value)"></div>'}).join("")
|
|
42869
|
+
+'</div><div class="card"><h2 style="font-size:1rem">Who is this for?</h2>'
|
|
42870
|
+
+'<p class="intro">'+(S.biz?escH(S.biz)+(S.trade?", "+escH(S.trade):""):"No business named yet")+'. <button class="mini" onclick="goBiz()">Edit on The client step</button></p></div>'}
|
|
42871
|
+
|
|
42872
|
+
function findHtml(){
|
|
42873
|
+
var r=R();
|
|
42874
|
+
var vb={findings:"note go",thin:"note warn",insufficient:"note stop"}[r.verdict];
|
|
42875
|
+
var h=warnsHtml()+'<h2>What it found</h2><div class="'+vb+'"><b>Verdict: '+r.verdict+'</b>'+r.line+'</div>';
|
|
42876
|
+
if(r.F.length){
|
|
42877
|
+
h+='<div class="card"><table><thead><tr><th>Finding</th><th class="num">A month</th></tr></thead><tbody>'
|
|
42878
|
+
+r.F.map(function(f){return '<tr><td>'+f.title+(f.asm?' <span class="unit">(uses your '+f.asm.replace(/_/g," ")+')</span>':'')+'</td><td class="num">'+money(f.monthly)+'</td></tr>'}).join("")
|
|
42879
|
+
+'<tr><td><b>Leaking, monthly</b></td><td class="num"><b>'+money(r.monthly)+'</b></td></tr>'
|
|
42880
|
+
+'<tr><td><b>Expected to stop, at your '+Math.round((S.asm.recovery||0)*100)+'%</b></td><td class="num"><b>'+money(r.stop)+'</b></td></tr>'
|
|
42881
|
+
+(r.oneoff!==null?'<tr><td>Dormant list, worked once <span class="unit">(one-off, not in the annual)</span></td><td class="num">'+money(r.oneoff)+'</td></tr>':'')
|
|
42882
|
+
+'</tbody></table></div>'}
|
|
42883
|
+
if(r.hrs.length){
|
|
42884
|
+
h+='<div class="card"><table><thead><tr><th>Where the time goes</th><th class="num">Hrs/week</th></tr></thead><tbody>'
|
|
42885
|
+
+r.hrs.map(function(x){return '<tr><td>'+x.t+'</td><td class="num">'+x.h.toFixed(1)+'</td></tr>'}).join("")
|
|
42886
|
+
+'<tr><td><b>Total</b></td><td class="num"><b>'+r.hrsWk.toFixed(1)+'</b></td></tr></tbody></table></div>'}
|
|
42887
|
+
if(r.O.length){
|
|
42888
|
+
h+='<div class="note warn"><b>Not priced, and deliberately not guessed</b>'
|
|
42889
|
+
+r.O.map(function(o){return o.declined?(o.title+" — you set "+o.declined.replace(/_/g," ")+" to zero")
|
|
42890
|
+
:(o.title+" — missing: "+o.miss.join(", "))}).join("<br>")+'</div>'}
|
|
42891
|
+
h+='<div class="btnrow"><button class="btn" onclick="tab(\\'rep\\')">Build the report</button></div>';
|
|
42892
|
+
return h}
|
|
42893
|
+
|
|
42894
|
+
function reportHtml(){
|
|
42895
|
+
var r=R();
|
|
42896
|
+
var answered=[];
|
|
42897
|
+
Q.forEach(function(q){
|
|
42898
|
+
var id=q[0];
|
|
42899
|
+
if(S.skip[id]) answered.push({ask:q[2],unknown:true,unit:q[4]});
|
|
42900
|
+
else if(S.ans[id]!==undefined&&String(S.ans[id]).trim()!=="") answered.push({ask:q[2],value:S.ans[id],unit:q[4]});
|
|
42901
|
+
});
|
|
42902
|
+
var labels={};
|
|
42903
|
+
ASSUMPTIONS.forEach(function(a){labels[a[0]]=a[3]});
|
|
42904
|
+
return renderReport({
|
|
42905
|
+
state:{biz:S.biz,trade:S.trade,ans:S.ans,client:S.client||{}},
|
|
42906
|
+
computed:r, agency:S.agency||window.COMMAND_OS_AGENCY||{}, services:S.services||[],
|
|
42907
|
+
assumptionLabels:labels,
|
|
42908
|
+
date:new Date().toLocaleDateString(undefined,{day:"numeric",month:"long",year:"numeric"}),
|
|
42909
|
+
answered:answered
|
|
42910
|
+
});
|
|
42911
|
+
}
|
|
42912
|
+
function repHtml(){
|
|
42913
|
+
var r=R();
|
|
42914
|
+
var doc=reportHtml();
|
|
42915
|
+
return '<h2>The report</h2><p class="intro">This is the document, exactly as your prospect receives it. Your branding, your prices, every estimate marked where it is used.</p>'
|
|
42916
|
+
+warnsHtml()
|
|
42917
|
+
+'<div class="card" style="padding:0;overflow:hidden"><iframe id="rpv" style="width:100%;height:34rem;border:0;background:#fff" title="Report preview"></iframe></div>'
|
|
42918
|
+
+'<div class="btnrow"><button class="btn" onclick="openReport()">Open it full size</button>'
|
|
42919
|
+
+'<button class="btn ghost" onclick="showSource()">Save the report</button>'
|
|
42920
|
+
+'<button class="btn ghost" onclick="dl()">Save answers</button>'
|
|
42921
|
+
+(window.COMMAND_OS_CLIENTS?'<button class="btn" onclick="saveToGhl()">Save to GoHighLevel</button>':'')+'</div>'
|
|
42922
|
+
+'<div id="savemsg"></div>'
|
|
42923
|
+
+'<div class="note"><b>Publishing it</b>Open it full size and print to PDF, or hand the file to your own site. Putting it on your domain automatically is the next piece, and it is not built yet.</div>';
|
|
42924
|
+
}
|
|
42925
|
+
function paintReport(){
|
|
42926
|
+
var f=document.getElementById("rpv");
|
|
42927
|
+
if(f) f.srcdoc=reportHtml();
|
|
42928
|
+
}
|
|
42929
|
+
function openReport(){
|
|
42930
|
+
var w=window.open("","_blank");
|
|
42931
|
+
if(w){w.document.open();w.document.write(reportHtml());w.document.close();}
|
|
42932
|
+
}
|
|
42933
|
+
function showSource(){
|
|
42934
|
+
var text=reportHtml(), name=slug(S.biz)+"-report.html";
|
|
42935
|
+
dump(savedMsg(saveFile(name,text,"text/html"),name), text);
|
|
42936
|
+
}
|
|
42937
|
+
function draw(){
|
|
42938
|
+
head();
|
|
42939
|
+
document.getElementById("rail").innerHTML=railHtml();
|
|
42940
|
+
document.getElementById("body").innerHTML=
|
|
42941
|
+
S.tab==="ask"?askHtml():S.tab==="set"?setHtml():S.tab==="find"?findHtml():repHtml();
|
|
42942
|
+
if(S.tab==="rep") paintReport();
|
|
42943
|
+
Array.prototype.forEach.call(document.querySelectorAll(".tabs button"),function(b){
|
|
42944
|
+
b.className=b.dataset.t===S.tab?"on":""})}
|
|
42945
|
+
|
|
42946
|
+
function setA(id,v){S.ans[id]=v;if(v!=="")S.skip[id]=false;save();head();
|
|
42947
|
+
var el=document.getElementById("q_"+id);if(el)el.className="q "+(v!==""?"done":"")}
|
|
42948
|
+
function skip(id){S.skip[id]=!S.skip[id];if(S.skip[id])S.ans[id]="";save();draw()}
|
|
42949
|
+
function why(id){var el=document.getElementById("q_"+id);if(el)el.classList.toggle("open")}
|
|
42950
|
+
function setAsm(k,v){S.asm[k]=parseRate(v);save();head()}
|
|
42951
|
+
function goArea(a){S.area=a;save();draw();window.scrollTo({top:0,behavior:"smooth"})}
|
|
42952
|
+
function nextArea(){if(S.area==="client"){goArea(AREAS[0][0]);return}var i=AREAS.map(function(x){return x[0]}).indexOf(S.area);
|
|
42953
|
+
if(i<AREAS.length-1)goArea(AREAS[i+1][0]);else tab("find")}
|
|
42954
|
+
function tab(t){S.tab=t;save();draw();window.scrollTo({top:0,behavior:"smooth"})}
|
|
42955
|
+
/** One copy box, two callers. */
|
|
42956
|
+
function dump(title,text){
|
|
42957
|
+
var d=document.getElementById("dump"); if(d)d.remove();
|
|
42958
|
+
var w=document.createElement("div");w.id="dump";w.className="card";
|
|
42959
|
+
w.innerHTML='<b style="font-size:var(--fs-sm)"></b>'
|
|
42960
|
+
+'<textarea class="a" style="min-height:12rem;font-family:var(--mono);font-size:var(--fs-sm)"></textarea>';
|
|
42961
|
+
w.querySelector("b").textContent=title;
|
|
42962
|
+
document.getElementById("body").appendChild(w);
|
|
42963
|
+
var t=w.querySelector("textarea");t.value=text;t.focus();t.select();
|
|
42964
|
+
w.scrollIntoView({behavior:"smooth",block:"nearest"});
|
|
42965
|
+
}
|
|
42966
|
+
function slug(s){return String(s||"assessment").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"assessment"}
|
|
42967
|
+
/**
|
|
42968
|
+
* Write a real file to the operator's downloads.
|
|
42969
|
+
*
|
|
42970
|
+
* The button said "Save answers" and saved nothing \u2014 it opened a textarea and
|
|
42971
|
+
* expected a human to select and copy it. Now it downloads, and the copy box
|
|
42972
|
+
* stays as the fallback for the browsers that refuse a download from file://.
|
|
42973
|
+
*/
|
|
42974
|
+
function saveFile(name,text,type){
|
|
42975
|
+
try{
|
|
42976
|
+
var b=new Blob([text],{type:type});
|
|
42977
|
+
var u=URL.createObjectURL(b);
|
|
42978
|
+
var a=document.createElement("a");a.href=u;a.download=name;a.style.display="none";
|
|
42979
|
+
document.body.appendChild(a);a.click();document.body.removeChild(a);
|
|
42980
|
+
setTimeout(function(){URL.revokeObjectURL(u)},4000);
|
|
42981
|
+
return true;
|
|
42982
|
+
}catch(e){return false}
|
|
42983
|
+
}
|
|
42984
|
+
/**
|
|
42985
|
+
* What to say about a download we cannot confirm.
|
|
42986
|
+
*
|
|
42987
|
+
* \`a.click()\` throws when the API is missing, and stays silent when the browser
|
|
42988
|
+
* simply refuses \u2014 which is exactly what some Chrome configurations do to a page
|
|
42989
|
+
* opened from file://, the way this one ships. So a true return means "asked",
|
|
42990
|
+
* never "saved", so announcing a completed save on the strength of it would be
|
|
42991
|
+
* a confident claim about something unobserved. The copy box is shown every
|
|
42992
|
+
* time and the wording is true under both outcomes.
|
|
42993
|
+
*/
|
|
42994
|
+
function savedMsg(ok,name){
|
|
42995
|
+
return ok
|
|
42996
|
+
? "Asked your browser to save it as "+name+". If it did not appear in your downloads, it was blocked \u2014 the same text is here, so select all, copy, and save it as "+name+"."
|
|
42997
|
+
: "Your browser would not save the file. Select all, copy, and save it as "+name+".";
|
|
42998
|
+
}
|
|
42999
|
+
function dl(){
|
|
43000
|
+
var text=JSON.stringify(S,null,2), name=slug(S.biz)+"-answers.json";
|
|
43001
|
+
dump(savedMsg(saveFile(name,text,"application/json"),name), text);
|
|
43002
|
+
}
|
|
43003
|
+
function wipe(){if(!confirm)return;S.ans={};S.skip={};S.biz="";S.trade="";S.client={};S.tab="ask";S.area="client";save();draw()}
|
|
43004
|
+
/** Save into the chosen account through Command OS on this computer: the same
|
|
43005
|
+
* write the save_assessment tool makes, nothing else. */
|
|
43006
|
+
function saveToGhl(){
|
|
43007
|
+
var box=document.getElementById("savemsg");
|
|
43008
|
+
var loc=S.saveTo;try{if(!loc)loc=localStorage.getItem(K+":saveTo")||""}catch(e){}
|
|
43009
|
+
if(!loc){if(box)box.innerHTML='<div class="note warn"><b>Which account?</b>Pick it on The client step first. <button class="mini" onclick="goBiz()">Take me there</button></div>';return}
|
|
43010
|
+
if(!S.biz||!S.biz.trim()){if(box)box.innerHTML='<div class="note warn"><b>No business name yet</b><button class="mini" onclick="goBiz()">Take me to it</button></div>';return}
|
|
43011
|
+
if(box)box.innerHTML='<div class="note"><b>Saving\u2026</b>The prospect as a contact, the answers as a note, the deal on your Assessments board.</div>';
|
|
43012
|
+
var c=S.client||{};
|
|
43013
|
+
fetch("/api/assessment-save",{method:"POST",headers:{"Content-Type":"application/json","x-command-os-seat":window.COMMAND_OS_SEAT||""},
|
|
43014
|
+
body:JSON.stringify({locationId:loc,assessment:{biz:S.biz,trade:S.trade,ans:S.ans,skip:S.skip,asm:S.asm,client:c},contactName:c.name||"",email:c.email||"",phone:c.phone||""})})
|
|
43015
|
+
.then(function(r){return r.json()}).then(function(j){
|
|
43016
|
+
if(!box)return;
|
|
43017
|
+
if(!j.ok||j.saved===false){box.innerHTML='<div class="note stop"><b>Not saved</b>'+String(j.error||"unknown").replace(/</g,"<")+'</div>';return}
|
|
43018
|
+
box.innerHTML='<div class="note go"><b>Saved</b>'+String(j.whereItLives||"").replace(/</g,"<")+' Stage: '+String(j.stage||"")+'. '+String(j.opportunityNote||"").replace(/</g,"<")+(j.warning?' '+String(j.warning).replace(/</g,"<"):'')+'</div>'})
|
|
43019
|
+
.catch(function(e){if(box)box.innerHTML='<div class="note stop"><b>Not saved</b>Command OS is not answering on this computer.</div>'})}
|
|
43020
|
+
|
|
43021
|
+
Array.prototype.forEach.call(document.querySelectorAll(".tabs button"),function(b){
|
|
43022
|
+
b.onclick=function(){tab(b.dataset.t)}});
|
|
43023
|
+
draw();
|
|
43024
|
+
</script>
|
|
43025
|
+
</body>
|
|
43026
|
+
</html>
|
|
43027
|
+
`;
|
|
43028
|
+
}
|
|
43029
|
+
});
|
|
43030
|
+
|
|
41318
43031
|
// src/command-os/design/brand-page.ts
|
|
41319
43032
|
function head2(title2) {
|
|
41320
43033
|
return `${cockpitHeadHtml(`${esc4(title2)} \u2014 Command OS`, { extraCss: STYLE })}<body>
|
|
@@ -41352,7 +43065,8 @@ function brandCardHtml(d, opts = {}) {
|
|
|
41352
43065
|
const sw = (label, t) => `<tr><td><span class="sw" style="margin:0"><span style="background:${t.hex}"></span></span></td><td>${esc4(label)}</td><td class="hex">${esc4(t.hex)}</td><td class="small">${esc4(t.source)}</td></tr>`;
|
|
41353
43066
|
const face = (label, f) => `<tr><td>${esc4(label)}</td><td><b>${esc4(f.family)}</b></td><td>${f.renderIntent === "google" ? `<span class="pill ok">Google Fonts</span>` : `<span class="pill warn">${f.renderIntent === "licensed-webfont" ? "licensed" : "system"}</span> renders as <b>${esc4(renderedFamily(f))}</b>`}</td><td class="small">${esc4(f.source)}</td></tr>`;
|
|
41354
43067
|
const pairs = d.contrast.pairs.map((p) => `<tr><td class="hex">${esc4(p.fg)} on ${esc4(p.bg)}</td><td>${p.ratio}:1 ${p.ratio >= 4.5 ? `<span class="pill ok">AA</span>` : `<span class="pill warn">under 4.5</span>`}</td><td class="small">${p.correctedFg ? `text shade corrected to <span class="hex">${esc4(p.correctedFg)}</span> (${p.correctedRatio}:1)` : ""}</td></tr>`).join("");
|
|
41355
|
-
const
|
|
43068
|
+
const cleanSvg = d.logo?.inlineSvg ? sanitizeSvgOrDrop(d.logo.inlineSvg) : void 0;
|
|
43069
|
+
const logo = cleanSvg ? `<img src="data:image/svg+xml;base64,${Buffer.from(cleanSvg.replace(/currentColor/g, c.fg.hex)).toString("base64")}" alt="">` : d.logo?.inlineSvg ? `<span class="small">the stored logo is not a plain drawing, so it is not shown</span>` : isRasterDataUri(d.logo?.dataUri) ? `<img src="${esc4(d.logo.dataUri)}" alt="">` : d.logo?.dataUri ? `<span class="small">the stored logo image is not a plain picture, so it is not shown</span>` : `<span class="small">no logo stored</span>`;
|
|
41356
43070
|
return `${head2(`${d.name} \xB7 brand`)}
|
|
41357
43071
|
<h1>${esc4(d.name)}</h1>
|
|
41358
43072
|
<p class="muted">${esc4(d.website ?? "")} \xB7 <span class="pill">${esc4(d.provenance.extractedVia)}</span><span class="pill ${d.provenance.confidence >= 0.7 ? "ok" : "warn"}">confidence ${Math.round(d.provenance.confidence * 100)}%</span> ${esc4(d.scheme)} scheme \xB7 ${esc4(d.provenance.extractedAt.slice(0, 10))}</p>
|
|
@@ -41403,7 +43117,7 @@ document.getElementById("make").addEventListener("submit",async function(e){
|
|
|
41403
43117
|
var g=function(id){return document.getElementById(id).value.trim()};
|
|
41404
43118
|
var pts=g("mk-points").split("|").map(function(x){return x.trim()}).filter(Boolean);
|
|
41405
43119
|
if(!g("mk-headline")||!pts.length){say("mk-status","A headline and at least one point, please.","err");return}
|
|
41406
|
-
var body={brand:${
|
|
43120
|
+
var body={brand:${scriptJson(d.slug)},medium:g("mk-medium"),headline:g("mk-headline"),points:pts};
|
|
41407
43121
|
if(g("mk-audience"))body.audience=g("mk-audience");
|
|
41408
43122
|
if(g("mk-cta"))body.cta=g("mk-cta");
|
|
41409
43123
|
if(g("mk-grade"))body.gradeCopyFor=g("mk-grade");
|
|
@@ -41433,7 +43147,7 @@ document.getElementById("mk-cancel").addEventListener("click",async function(){
|
|
|
41433
43147
|
try{await post("/api/design/cancel",{id:runId});stopPolling();say("mk-status","Stopped.");document.getElementById("mk-go").disabled=false;this.hidden=true}catch(e){say("mk-status",e.message,"err")}
|
|
41434
43148
|
});
|
|
41435
43149
|
})();
|
|
41436
|
-
document.getElementById("override").addEventListener("submit",async(e)=>{e.preventDefault();const g=(id)=>document.getElementById(id).value.trim();const body={slug:${
|
|
43150
|
+
document.getElementById("override").addEventListener("submit",async(e)=>{e.preventDefault();const g=(id)=>document.getElementById(id).value.trim();const body={slug:${scriptJson(d.slug)}};if(g("accent"))body.accent=g("accent");if(g("displayFont"))body.displayFont=g("displayFont");if(g("bodyFont"))body.bodyFont=g("bodyFont");if(g("brandName"))body.name=g("brandName");if(g("logoSvg"))body.logoSvg=g("logoSvg");if(Object.keys(body).length===1){say("save-status","Nothing to change.");return}const b=document.getElementById("save");b.disabled=true;try{await post("/api/brand/set",body);say("save-status","Saved.","ok");location.reload()}catch(err){say("save-status",err.message,"err");b.disabled=false}});
|
|
41437
43151
|
`)}`;
|
|
41438
43152
|
}
|
|
41439
43153
|
var esc4, STYLE, SHELL_JS, foot;
|
|
@@ -41442,6 +43156,8 @@ var init_brand_page = __esm({
|
|
|
41442
43156
|
"use strict";
|
|
41443
43157
|
init_fonts();
|
|
41444
43158
|
init_cockpit_shell();
|
|
43159
|
+
init_script_json();
|
|
43160
|
+
init_svg_sanitize();
|
|
41445
43161
|
esc4 = (s) => String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
41446
43162
|
STYLE = `
|
|
41447
43163
|
main{max-width:1080px;margin:0 auto;padding:28px 24px 80px}
|
|
@@ -41469,13 +43185,13 @@ td.hex{font-family:ui-monospace,Menlo,monospace}
|
|
|
41469
43185
|
.shot{max-width:100%;border:1px solid var(--line);border-radius:var(--radius-sm)}
|
|
41470
43186
|
`;
|
|
41471
43187
|
SHELL_JS = (seatToken) => `
|
|
41472
|
-
const SERVED=${
|
|
43188
|
+
const SERVED=${scriptJson(seatToken)};
|
|
41473
43189
|
const KEY=SERVED||new URLSearchParams(location.search).get("seat")||localStorage.getItem("cockpit_seat")||"";
|
|
41474
43190
|
try{if(SERVED)localStorage.setItem("cockpit_seat",SERVED)}catch(e){}
|
|
41475
43191
|
async function post(url,body){const r=await fetch(url,{method:"POST",headers:{"Content-Type":"application/json","x-command-os-seat":KEY},body:JSON.stringify(body)});let j={};try{j=await r.json()}catch(e){}if(!r.ok||j.ok===false)throw new Error(j.error||("Request failed ("+r.status+")"));return j}
|
|
41476
43192
|
function say(id,msg,cls){const el=document.getElementById(id);if(!el)return;el.textContent=msg;el.style.color=cls==="err"?"#A63D3D":cls==="ok"?"#2F7A5A":""}
|
|
41477
43193
|
`;
|
|
41478
|
-
foot = (js) => `</main
|
|
43194
|
+
foot = (js) => `</main>${cockpitShellClose()}<script>${js}</script><script>${COCKPIT_TYPE_JS}</script></body></html>`;
|
|
41479
43195
|
}
|
|
41480
43196
|
});
|
|
41481
43197
|
|
|
@@ -41763,7 +43479,7 @@ function renderClientStatusHtml(input) {
|
|
|
41763
43479
|
</div>
|
|
41764
43480
|
</div></div>
|
|
41765
43481
|
<div class="page"><div class="sheet">
|
|
41766
|
-
${agency || input.logoDataUri ? `<div class="brand">${input.logoDataUri ? `<img src="${esc5(input.logoDataUri)}" alt="${esc5(agency ?? "")}">` : ""}${agency ? `<span class="agency">${esc5(agency)}</span>` : ""}<span class="for">Prepared for<br><b>${esc5(input.accountName)}</b></span></div>` : ""}
|
|
43482
|
+
${agency || isRasterDataUri(input.logoDataUri) ? `<div class="brand">${isRasterDataUri(input.logoDataUri) ? `<img src="${esc5(input.logoDataUri)}" alt="${esc5(agency ?? "")}">` : ""}${agency ? `<span class="agency">${esc5(agency)}</span>` : ""}<span class="for">Prepared for<br><b>${esc5(input.accountName)}</b></span></div>` : ""}
|
|
41767
43483
|
<p class="eyebrow">What is live in your account</p>
|
|
41768
43484
|
<h1>${esc5(input.accountName)}</h1>
|
|
41769
43485
|
<p class="stamp">${esc5(revLine)}</p>
|
|
@@ -41812,6 +43528,7 @@ var init_client_status = __esm({
|
|
|
41812
43528
|
"use strict";
|
|
41813
43529
|
init_handoff_pack();
|
|
41814
43530
|
init_branding();
|
|
43531
|
+
init_svg_sanitize();
|
|
41815
43532
|
CLIENT_STATE_LABEL = {
|
|
41816
43533
|
"in-place": "Live in your account",
|
|
41817
43534
|
"not-yet": "Not confirmed yet",
|
|
@@ -42475,6 +44192,76 @@ var init_blueprint_review = __esm({
|
|
|
42475
44192
|
}
|
|
42476
44193
|
});
|
|
42477
44194
|
|
|
44195
|
+
// src/command-os/capability-runs.ts
|
|
44196
|
+
function capabilitySentence(cap, clientName) {
|
|
44197
|
+
return cap.says.replace(/\{client\}/g, clientName);
|
|
44198
|
+
}
|
|
44199
|
+
function capabilitySpec(cap, clientName) {
|
|
44200
|
+
const tools = CAPABILITY_TOOLS[cap.key] ?? [];
|
|
44201
|
+
if (!tools.length) throw new Error(`${cap.label} is not built from a button. Run it from your own Claude, watching.`);
|
|
44202
|
+
return {
|
|
44203
|
+
stage: -1,
|
|
44204
|
+
name: cap.label,
|
|
44205
|
+
allowedTools: T([...ALWAYS, ...tools]),
|
|
44206
|
+
maxTurns: 30,
|
|
44207
|
+
timeoutMs: 15 * 6e4,
|
|
44208
|
+
prompt: (locationId2, locationName) => `You are Command OS, working inside ONE GoHighLevel sub-account for a client: ${locationName} (${locationId2}).
|
|
44209
|
+
1. Call switch_location with locationId ${locationId2}.
|
|
44210
|
+
2. Call get_current_location and STOP with a line "RESULT: {\\"ok\\":false,\\"error\\":\\"wrong location\\"}" if it is not ${locationId2}.
|
|
44211
|
+
3. Do exactly this, and nothing beyond it: ${capabilitySentence(cap, clientName)}
|
|
44212
|
+
Rules that are not negotiable:
|
|
44213
|
+
- Read what the account already has BEFORE you create anything, and reuse what is there instead of duplicating it.
|
|
44214
|
+
- Any workflow you create or change stays a DRAFT. Never publish. Never delete anything. Never send a message to a real contact.
|
|
44215
|
+
- Every id you use (calendar, pipeline, stage, field, user, workflow) comes from a read of THIS account in this run, never from memory or another account.
|
|
44216
|
+
- If a step needs a human (a number to buy, a Stripe connection, a staff member to add, a decision), do not guess: list it as an issue.
|
|
44217
|
+
- Say what you did in the owner's words, not tool names.
|
|
44218
|
+
Finish with ONE line, exactly:
|
|
44219
|
+
RESULT: {"ok":true,"summary":"<what exists now versus what was already there, with counts>","issues":["<anything skipped or waiting on a person>"]}
|
|
44220
|
+
If it cannot be done, finish with: RESULT: {"ok":false,"error":"<short reason>"}`
|
|
44221
|
+
};
|
|
44222
|
+
}
|
|
44223
|
+
var T, ALWAYS, CAPABILITY_TOOLS;
|
|
44224
|
+
var init_capability_runs = __esm({
|
|
44225
|
+
"src/command-os/capability-runs.ts"() {
|
|
44226
|
+
"use strict";
|
|
44227
|
+
T = (names) => names.map((n) => `mcp__ghl__${n}`);
|
|
44228
|
+
ALWAYS = ["switch_location", "get_current_location"];
|
|
44229
|
+
CAPABILITY_TOOLS = {
|
|
44230
|
+
intake: ["get_forms", "get_form_full", "get_form_submissions", "get_intake_question_set"],
|
|
44231
|
+
pipelines: ["get_pipelines", "get_pipeline_full", "create_pipeline", "update_pipeline"],
|
|
44232
|
+
calendars: ["get_calendars", "get_calendar", "get_users", "create_calendar", "update_calendar"],
|
|
44233
|
+
forms: ["get_forms", "get_form_full", "get_form_submissions", "create_form", "update_form", "get_surveys", "get_custom_fields"],
|
|
44234
|
+
pages: ["get_funnels", "get_funnel_pages", "get_page_full", "list_brands", "get_brand", "get_calendars", "find_ghl_template", "install_ghl_template", "create_funnel", "create_funnel_page", "compose_page", "update_page_content", "verify_funnel", "check_page_theme"],
|
|
44235
|
+
fields: ["get_custom_fields", "create_custom_field", "update_custom_field", "get_custom_values", "create_custom_value", "update_custom_value", "get_location_tags", "create_location_tag"],
|
|
44236
|
+
staff: ["get_users", "get_user", "get_calendars"],
|
|
44237
|
+
phone: ["list_phone_numbers", "list_number_pools", "get_location"],
|
|
44238
|
+
workflows: ["get_workflows", "get_workflow_full", "get_trigger_registry", "get_users", "get_calendars", "get_pipelines", "get_pipeline_full", "get_custom_fields", "get_custom_values", "get_location_tags", "list_email_templates", "list_message_templates", "validate_workflow", "create_workflow", "update_workflow_actions", "audit_workflows"],
|
|
44239
|
+
reactivation: ["get_contact_count", "search_contacts", "get_location_tags", "create_location_tag", "get_workflows", "get_workflow_full", "validate_workflow", "create_workflow", "update_workflow_actions", "create_smart_list", "list_smart_lists"],
|
|
44240
|
+
// no bulk tagging: that is a mass write the operator does by hand
|
|
44241
|
+
templates: ["list_email_templates", "create_email_template", "update_email_template", "list_message_templates", "create_sms_template", "get_custom_values"],
|
|
44242
|
+
broadcasts: ["list_email_templates", "create_email_template", "update_email_template", "list_smart_lists", "get_email_campaigns"],
|
|
44243
|
+
// drafts the email as a template; sending or scheduling is the operator's click
|
|
44244
|
+
"conversation-ai": ["list_knowledge_bases", "get_knowledge_base", "create_knowledge_base", "crawl_kb_website", "get_kb_crawl_status", "train_kb_urls", "list_kb_urls", "list_kb_faqs", "create_kb_faq", "list_conversation_agents", "get_conversation_agent", "create_conversation_agent", "list_conversation_actions", "create_conversation_action", "get_agent_generations", "get_calendars", "get_users"],
|
|
44245
|
+
"voice-ai": ["check_voice_ai_readiness", "list_voice_agents", "get_voice_agent", "create_voice_agent", "get_voice_action", "create_voice_action", "update_voice_action", "get_voice_call_logs", "get_calendars", "get_users", "get_workflows", "list_knowledge_bases"],
|
|
44246
|
+
demo: [],
|
|
44247
|
+
// messages a real prospect: never from a button (NO_BUILD_BUTTON)
|
|
44248
|
+
payments: ["list_products", "get_product", "list_product_prices", "create_product", "update_product", "list_invoices", "get_invoice", "create_invoice", "list_coupons"],
|
|
44249
|
+
documents: [],
|
|
44250
|
+
// reads only would be a brochure and a send is not a build: no button (NO_BUILD_BUTTON)
|
|
44251
|
+
memberships: ["get_courses", "create_course", "list_membership_categories", "create_membership_category", "list_membership_lessons", "create_membership_lesson", "list_membership_offers", "create_membership_offer"],
|
|
44252
|
+
reviews: ["list_reviews", "get_review_link_list", "get_workflows", "get_workflow_full", "validate_workflow", "create_workflow", "update_workflow_actions", "get_calendars"],
|
|
44253
|
+
social: [],
|
|
44254
|
+
// a post is a send: no button (NO_BUILD_BUTTON)
|
|
44255
|
+
blogs: ["get_blogs", "get_blog_posts", "get_blog_post", "get_blog_authors", "get_blog_categories", "check_blog_slug"],
|
|
44256
|
+
checkup: ["run_checkup", "audit_workflows", "get_account_health_summary", "audit_my_delivery", "build_audit_report", "get_workflows"],
|
|
44257
|
+
contacts: ["get_contact_count", "search_contacts", "get_contact", "get_location_tags", "create_location_tag", "create_smart_list", "list_smart_lists"],
|
|
44258
|
+
// no bulk writes from a button
|
|
44259
|
+
integrations: ["list_webhooks", "get_webhook", "create_webhook", "get_trigger_links", "create_trigger_link", "list_custom_objects", "get_custom_object"],
|
|
44260
|
+
handoff: ["build_offboarding_kit", "build_audit_report", "list_snapshots", "create_snapshot_share_link", "export_account", "run_checkup"]
|
|
44261
|
+
};
|
|
44262
|
+
}
|
|
44263
|
+
});
|
|
44264
|
+
|
|
42478
44265
|
// src/shared-brain.ts
|
|
42479
44266
|
function isSharedRecord(x) {
|
|
42480
44267
|
const r = x;
|
|
@@ -42558,33 +44345,47 @@ var init_shared_brain = __esm({
|
|
|
42558
44345
|
var dashboard_exports = {};
|
|
42559
44346
|
__export(dashboard_exports, {
|
|
42560
44347
|
ACCOUNT_TYPES: () => ACCOUNT_TYPES,
|
|
44348
|
+
INTAKE_OWN_MESSAGES: () => INTAKE_OWN_MESSAGES,
|
|
42561
44349
|
NOT_SIGNED_IN_MSG: () => NOT_SIGNED_IN_MSG,
|
|
44350
|
+
SEAT_GUARDED_READS: () => SEAT_GUARDED_READS,
|
|
42562
44351
|
SEAT_TOKEN_HEADER: () => SEAT_TOKEN_HEADER,
|
|
42563
44352
|
STAGES: () => STAGES2,
|
|
42564
44353
|
accountTypeOf: () => accountTypeOf,
|
|
42565
|
-
|
|
44354
|
+
assessmentAgencyTag: () => assessmentAgencyTag,
|
|
42566
44355
|
checkMutationGuard: () => checkMutationGuard,
|
|
42567
44356
|
checkSeatToken: () => checkSeatToken,
|
|
42568
44357
|
clearPending: () => clearPending,
|
|
44358
|
+
clientDocsDir: () => clientDocsDir,
|
|
44359
|
+
cockpitGate: () => cockpitGate,
|
|
42569
44360
|
cockpitStatePath: () => cockpitStatePath,
|
|
42570
44361
|
forgetAnthropicKey: () => forgetAnthropicKey,
|
|
44362
|
+
guarded: () => guarded,
|
|
44363
|
+
insideDir: () => insideDir,
|
|
42571
44364
|
isLoopback: () => isLoopback,
|
|
42572
44365
|
launcherAlreadyOffered: () => launcherAlreadyOffered,
|
|
44366
|
+
listClientDocs: () => listClientDocs,
|
|
44367
|
+
memberFacingError: () => memberFacingError,
|
|
42573
44368
|
overlayPath: () => overlayPath,
|
|
44369
|
+
readBody: () => readBody,
|
|
42574
44370
|
readCockpitState: () => readCockpitState,
|
|
44371
|
+
readFailureText: () => readFailureText,
|
|
42575
44372
|
readOverlay: () => readOverlay,
|
|
44373
|
+
readableAccount: () => readableAccount,
|
|
42576
44374
|
recordLauncherOffered: () => recordLauncherOffered,
|
|
42577
44375
|
recoverStuckStages: () => recoverStuckStages,
|
|
42578
44376
|
renderCockpitHtml: () => renderCockpitHtml,
|
|
42579
44377
|
resolvePlan: () => resolvePlan2,
|
|
42580
44378
|
runDashboard: () => runDashboard,
|
|
44379
|
+
safeDocName: () => safeDocName,
|
|
42581
44380
|
setAccountIndustry: () => setAccountIndustry,
|
|
42582
44381
|
setAccountType: () => setAccountType,
|
|
42583
44382
|
setBuildApproval: () => setBuildApproval,
|
|
42584
44383
|
setPending: () => setPending,
|
|
42585
44384
|
setStage: () => setStage,
|
|
44385
|
+
uniqueDocName: () => uniqueDocName,
|
|
42586
44386
|
withCockpitShell: () => withCockpitShell,
|
|
42587
44387
|
writeCockpitState: () => writeCockpitState,
|
|
44388
|
+
writeDocAtomically: () => writeDocAtomically,
|
|
42588
44389
|
writeOverlay: () => writeOverlay
|
|
42589
44390
|
});
|
|
42590
44391
|
function recoverStuckStages(state) {
|
|
@@ -42596,25 +44397,6 @@ function recoverStuckStages(state) {
|
|
|
42596
44397
|
}));
|
|
42597
44398
|
return { state: { ...state, clients }, recovered };
|
|
42598
44399
|
}
|
|
42599
|
-
function assessmentPagePath(dirname14 = __dirname) {
|
|
42600
|
-
const candidates = [
|
|
42601
|
-
path31.join(dirname14, "assessment.html"),
|
|
42602
|
-
// dist/, as published
|
|
42603
|
-
path31.join(dirname14, "..", "dist", "assessment.html"),
|
|
42604
|
-
// running from src/
|
|
42605
|
-
path31.join(dirname14, "..", "src", "command-os", "assessment", "app.html"),
|
|
42606
|
-
// dist/ in a dev checkout
|
|
42607
|
-
path31.join(dirname14, "command-os", "assessment", "app.html")
|
|
42608
|
-
// src/
|
|
42609
|
-
];
|
|
42610
|
-
for (const c of candidates) {
|
|
42611
|
-
try {
|
|
42612
|
-
if (fs32.existsSync(c) && fs32.statSync(c).isFile()) return c;
|
|
42613
|
-
} catch {
|
|
42614
|
-
}
|
|
42615
|
-
}
|
|
42616
|
-
return null;
|
|
42617
|
-
}
|
|
42618
44400
|
function cockpitStatePath() {
|
|
42619
44401
|
return path31.join(appDataDir(), "cockpit-state.json");
|
|
42620
44402
|
}
|
|
@@ -42697,7 +44479,7 @@ async function resolvePlan2(creds) {
|
|
|
42697
44479
|
return { error: "Could not verify the license (offline and no valid cached attestation)." };
|
|
42698
44480
|
}
|
|
42699
44481
|
function startRun(locationId2, stage, onDone, meta = {}) {
|
|
42700
|
-
activeRun2 = { locationId: locationId2, stage, running: true, lines: [] };
|
|
44482
|
+
activeRun2 = { locationId: locationId2, stage, running: true, lines: [], ...meta.capability ? { capability: meta.capability } : {} };
|
|
42701
44483
|
const run = activeRun2;
|
|
42702
44484
|
const state = readCockpitState();
|
|
42703
44485
|
const industry = state.accounts?.[locationId2]?.industry;
|
|
@@ -42723,7 +44505,7 @@ function startRun(locationId2, stage, onDone, meta = {}) {
|
|
|
42723
44505
|
if (run.lines.length > 200) run.lines.shift();
|
|
42724
44506
|
}, { context, name: meta.name, approvedAt: meta.approvedAt, onProgress: (p) => {
|
|
42725
44507
|
run.progress = p;
|
|
42726
|
-
} }).then(async (outcome) => {
|
|
44508
|
+
}, spec: meta.spec }).then(async (outcome) => {
|
|
42727
44509
|
try {
|
|
42728
44510
|
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
42729
44511
|
const shared = { at, locationId: locationId2, stage, engineVersion: outcome.engineVersion, engineModel: outcome.engineModel };
|
|
@@ -42775,7 +44557,7 @@ function startRun(locationId2, stage, onDone, meta = {}) {
|
|
|
42775
44557
|
}
|
|
42776
44558
|
run.ok = outcome.ok;
|
|
42777
44559
|
const before = readCockpitState().clients[locationId2]?.stages;
|
|
42778
|
-
onDone(outcome.ok);
|
|
44560
|
+
onDone(outcome.ok, outcome);
|
|
42779
44561
|
const after = readCockpitState().clients[locationId2]?.stages;
|
|
42780
44562
|
if (stage === 4) await refreshAfterVerify(locationId2, meta.name || locationId2, (l) => run.lines.push(l));
|
|
42781
44563
|
if (meta.writeReview && shouldAutoGenerateReview(before, after, Boolean(readCockpitState().reviews?.[locationId2]?.ok))) {
|
|
@@ -42855,16 +44637,164 @@ function recordLauncherOffered() {
|
|
|
42855
44637
|
} catch {
|
|
42856
44638
|
}
|
|
42857
44639
|
}
|
|
42858
|
-
function
|
|
42859
|
-
const
|
|
44640
|
+
function assessmentAgencyTag(ap = readAgencyProfile()) {
|
|
44641
|
+
const forReport = {
|
|
44642
|
+
name: ap.agencyName,
|
|
44643
|
+
accent: ap.accent,
|
|
44644
|
+
contact: [ap.sender?.replyTo, ap.sender?.phone, ap.website].filter(Boolean).join(" \xB7 ") || void 0,
|
|
44645
|
+
method: ap.agencyOs?.method,
|
|
44646
|
+
promise: ap.agencyOs?.offer?.promise,
|
|
44647
|
+
guarantee: ap.agencyOs?.offer?.guarantee
|
|
44648
|
+
};
|
|
44649
|
+
return `<script>window.COMMAND_OS_AGENCY=${scriptJson(forReport)}</script>`;
|
|
44650
|
+
}
|
|
44651
|
+
function withCockpitShell(page, screen, extraHead = "") {
|
|
44652
|
+
const css2 = `<style>${cockpitTokensCss({ webfont: false })}${cockpitChromeCss()}</style>${extraHead}`;
|
|
42860
44653
|
const header = `${cockpitHeaderHtml(screen)}<script>${COCKPIT_TYPE_JS}</script>`;
|
|
42861
44654
|
const withCss = page.includes("<!--COCKPIT_SHELL_CSS-->") ? page.replace("<!--COCKPIT_SHELL_CSS-->", css2) : page.replace("</head>", `${css2}</head>`);
|
|
42862
|
-
|
|
44655
|
+
const withHeader = withCss.includes("<!--COCKPIT_HEADER-->") ? withCss.replace("<!--COCKPIT_HEADER-->", header) : withCss.replace(/<body([^>]*)>/, `<body$1>${header}`);
|
|
44656
|
+
return withHeader.includes("</body>") ? withHeader.replace("</body>", `${cockpitShellClose()}</body>`) : withHeader + cockpitShellClose();
|
|
44657
|
+
}
|
|
44658
|
+
function clientDocsDir(locationId2, base = appDataDir()) {
|
|
44659
|
+
const safeLoc = String(locationId2).replace(/[^A-Za-z0-9_-]/g, "");
|
|
44660
|
+
if (!safeLoc) throw new Error("no client");
|
|
44661
|
+
return path31.join(base, "clients", safeLoc, "documents");
|
|
44662
|
+
}
|
|
44663
|
+
function safeDocName(name) {
|
|
44664
|
+
const cleaned = path31.basename(String(name ?? "")).replace(/[^A-Za-z0-9._ -]/g, "_").replace(/\s+/g, " ").trim();
|
|
44665
|
+
const ext = path31.extname(cleaned).toLowerCase();
|
|
44666
|
+
if (!cleaned || cleaned.startsWith(".") || !DOC_EXTS.has(ext)) throw new Error("Only PDF, Word (.docx/.doc), text, Markdown, RTF and CSV files can be saved here.");
|
|
44667
|
+
const stem = cleaned.slice(0, cleaned.length - ext.length).slice(0, 120 - ext.length).trim();
|
|
44668
|
+
return `${stem || "document"}${ext}`;
|
|
44669
|
+
}
|
|
44670
|
+
function listClientDocs(locationId2, base = appDataDir()) {
|
|
44671
|
+
const dir = clientDocsDir(locationId2, base);
|
|
44672
|
+
if (!fs32.existsSync(dir)) return [];
|
|
44673
|
+
return fs32.readdirSync(dir).filter((f) => DOC_EXTS.has(path31.extname(f).toLowerCase())).map((f) => {
|
|
44674
|
+
const st = fs32.statSync(path31.join(dir, f));
|
|
44675
|
+
return { name: f, bytes: st.size, savedAt: st.mtime.toISOString() };
|
|
44676
|
+
}).sort((a, b) => b.savedAt.localeCompare(a.savedAt));
|
|
44677
|
+
}
|
|
44678
|
+
function readFailureText(e) {
|
|
44679
|
+
const m = /GHL API Error (\d{3})/.exec(e instanceof Error ? e.message : String(e));
|
|
44680
|
+
if (m) {
|
|
44681
|
+
const code = m[1];
|
|
44682
|
+
if (code === "401" || code === "403") return "could not read: the key on file for this account is not allowed to";
|
|
44683
|
+
if (code === "404") return "could not read: GoHighLevel has no such list on this account";
|
|
44684
|
+
if (code === "429") return "could not read: GoHighLevel asked us to slow down; try again in a minute";
|
|
44685
|
+
return `could not read (HTTP ${code})`;
|
|
44686
|
+
}
|
|
44687
|
+
return "could not read";
|
|
44688
|
+
}
|
|
44689
|
+
function logQuietly(message) {
|
|
44690
|
+
try {
|
|
44691
|
+
ensureAppDataDir();
|
|
44692
|
+
fs32.appendFileSync(path31.join(appDataDir(), "cockpit.log"), `[${(/* @__PURE__ */ new Date()).toISOString()}] ${message}
|
|
44693
|
+
`);
|
|
44694
|
+
} catch {
|
|
44695
|
+
}
|
|
42863
44696
|
}
|
|
42864
|
-
function
|
|
42865
|
-
|
|
44697
|
+
function readBody(req, res, max, onDone, tooLarge = "That request is too large.") {
|
|
44698
|
+
let body = "";
|
|
44699
|
+
let tooBig = false;
|
|
44700
|
+
const fail = (e) => {
|
|
44701
|
+
logQuietly(`request handler: ${e instanceof Error ? e.stack ?? e.message : String(e)}`);
|
|
44702
|
+
try {
|
|
44703
|
+
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
|
44704
|
+
res.end(JSON.stringify({ ok: false, error: "That did not finish. The detail is in cockpit.log." }));
|
|
44705
|
+
} catch {
|
|
44706
|
+
}
|
|
44707
|
+
};
|
|
44708
|
+
req.on("data", (chunk) => {
|
|
44709
|
+
if (tooBig) return;
|
|
44710
|
+
body += chunk;
|
|
44711
|
+
if (body.length > max) {
|
|
44712
|
+
tooBig = true;
|
|
44713
|
+
body = "";
|
|
44714
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
44715
|
+
res.end(JSON.stringify({ ok: false, error: tooLarge }));
|
|
44716
|
+
req.resume?.();
|
|
44717
|
+
}
|
|
44718
|
+
});
|
|
44719
|
+
req.on("end", () => {
|
|
44720
|
+
if (tooBig) return;
|
|
44721
|
+
try {
|
|
44722
|
+
const r = onDone(body);
|
|
44723
|
+
if (r && typeof r.catch === "function") r.catch(fail);
|
|
44724
|
+
} catch (e) {
|
|
44725
|
+
fail(e);
|
|
44726
|
+
}
|
|
44727
|
+
});
|
|
44728
|
+
}
|
|
44729
|
+
function memberFacingError(e, own = []) {
|
|
44730
|
+
const raw = e instanceof Error ? e.message : String(e);
|
|
44731
|
+
return own.includes(raw) ? raw : readFailureText(e);
|
|
44732
|
+
}
|
|
44733
|
+
function insideDir(dir, p) {
|
|
44734
|
+
try {
|
|
44735
|
+
const real = fs32.realpathSync(p);
|
|
44736
|
+
const base = fs32.realpathSync(dir);
|
|
44737
|
+
return real === base || real.startsWith(base + path31.sep);
|
|
44738
|
+
} catch {
|
|
44739
|
+
return false;
|
|
44740
|
+
}
|
|
44741
|
+
}
|
|
44742
|
+
function uniqueDocName(dir, name) {
|
|
44743
|
+
const ext = path31.extname(name), stem = name.slice(0, name.length - ext.length);
|
|
44744
|
+
let candidate = name;
|
|
44745
|
+
for (let n = 2; fs32.existsSync(path31.join(dir, candidate)); n++) candidate = `${stem} (${n})${ext}`;
|
|
44746
|
+
return candidate;
|
|
44747
|
+
}
|
|
44748
|
+
function writeDocAtomically(dir, name, bytes) {
|
|
44749
|
+
fs32.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
44750
|
+
const finalName = uniqueDocName(dir, name);
|
|
44751
|
+
const tmp = path31.join(dir, `.${finalName}.${process.pid}.tmp`);
|
|
44752
|
+
const fd = fs32.openSync(tmp, fs32.constants.O_WRONLY | fs32.constants.O_CREAT | fs32.constants.O_EXCL | (fs32.constants.O_NOFOLLOW ?? 0), 384);
|
|
44753
|
+
try {
|
|
44754
|
+
fs32.writeSync(fd, bytes);
|
|
44755
|
+
fs32.fsyncSync(fd);
|
|
44756
|
+
} finally {
|
|
44757
|
+
fs32.closeSync(fd);
|
|
44758
|
+
}
|
|
44759
|
+
const dest = path31.join(dir, finalName);
|
|
44760
|
+
try {
|
|
44761
|
+
if (fs32.lstatSync(dest).isSymbolicLink()) {
|
|
44762
|
+
fs32.unlinkSync(tmp);
|
|
44763
|
+
throw new Error("that name is taken by a link");
|
|
44764
|
+
}
|
|
44765
|
+
} catch (e) {
|
|
44766
|
+
if (e.code !== "ENOENT") throw e;
|
|
44767
|
+
}
|
|
44768
|
+
fs32.renameSync(tmp, dest);
|
|
44769
|
+
return finalName;
|
|
44770
|
+
}
|
|
44771
|
+
function readableAccount(locationId2, roster = currentRosterRef(), state = readCockpitState()) {
|
|
44772
|
+
if (!locationId2 || !roster.some((r) => r.locationId === locationId2)) return false;
|
|
44773
|
+
const t = state.accounts?.[locationId2]?.type;
|
|
44774
|
+
return t !== "hidden" && t !== "template";
|
|
44775
|
+
}
|
|
44776
|
+
function html(roster, seatToken, screen = "board", cap) {
|
|
44777
|
+
const capForPage = cap ? { ...cap, groupLabel: CAPABILITY_GROUPS.find((g) => g.key === cap.group)?.label ?? cap.group, noBuild: NO_BUILD_BUTTON.has(cap.key) } : null;
|
|
44778
|
+
return `${cockpitHeadHtml(cap ? `${cap.label} \u2014 Command OS` : BOARD_TITLES[screen] ?? BOARD_TITLES.board, { extraCss: `
|
|
42866
44779
|
|
|
42867
44780
|
main{max-width:56rem;margin:1.4rem auto 4rem;padding:0 1rem}
|
|
44781
|
+
.screen-head{margin:0 0 1.2rem}.screen-head h1{font-family:var(--font-display);font-weight:500;font-size:1.6rem;color:var(--heading);margin:0 0 .3rem}
|
|
44782
|
+
.screen-head .lead{color:var(--ink-2);font-size:var(--fs-body);margin:0;max-width:40rem}
|
|
44783
|
+
.home{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:1rem}
|
|
44784
|
+
.home .tile{display:block;padding:1rem 1.1rem;color:inherit;text-decoration:none}
|
|
44785
|
+
.home .tile:hover{border-color:var(--gold);text-decoration:none}
|
|
44786
|
+
.home .big{font-family:var(--font-display);font-size:2.1rem;line-height:1.1;color:var(--heading);margin:.25rem 0 .35rem}
|
|
44787
|
+
.home .tile p{color:var(--ink-2);font-size:var(--fs-sm);margin:0}
|
|
44788
|
+
.home .go{display:inline-block;margin-top:.7rem;font-weight:600;font-size:var(--fs-sm);color:var(--gold-light)}
|
|
44789
|
+
a.newbtn{display:inline-flex;align-items:center;text-decoration:none}
|
|
44790
|
+
.capsel{font:inherit;font-size:var(--fs-body);padding:.45rem .7rem;border-radius:var(--radius-sm);border:1px solid var(--line);background:var(--surface);color:var(--heading);min-width:16rem}
|
|
44791
|
+
.cap-reads .cap-row{padding:.35rem 0;border-bottom:1px solid var(--line);font-size:var(--fs-body)}.cap-reads .cap-row:last-of-type{border-bottom:0}
|
|
44792
|
+
.cap-words{margin:0;padding:.8rem 1rem;border-left:3px solid var(--gold);background:var(--bg-2);border-radius:var(--radius-sm);font-size:var(--fs-lg);line-height:1.5;color:var(--heading)}
|
|
44793
|
+
.cap-log{margin:.8rem 0 0;padding:.8rem 1rem;background:var(--bg-2);border-radius:var(--radius-sm);font-size:var(--fs-sm);line-height:1.5;white-space:pre-wrap;max-height:22rem;overflow:auto}
|
|
44794
|
+
a.intakebtn{text-decoration:none;display:inline-flex;align-items:center}
|
|
44795
|
+
.muted{color:var(--ink-2)}.small{font-size:var(--fs-sm)}
|
|
44796
|
+
.idrop{border:1px dashed var(--line);border-radius:var(--radius-sm);padding:.9rem 1rem;color:var(--ink-2);font-size:var(--fs-sm);background:var(--surface)}
|
|
44797
|
+
.idocs{margin-top:.6rem}.idoc{display:flex;align-items:center;justify-content:space-between;gap:.6rem;padding:.45rem 0;border-bottom:1px solid var(--line);font-size:var(--fs-sm)}.idoc:last-child{border-bottom:0}
|
|
42868
44798
|
footer{color:var(--ink-3);font-size:var(--fs-sm);text-align:center;margin:2rem 0;padding:0 1rem}
|
|
42869
44799
|
|
|
42870
44800
|
/* \u2500\u2500 Building blocks (hub) \u2500\u2500 */
|
|
@@ -42997,7 +44927,7 @@ function html(roster, seatToken) {
|
|
|
42997
44927
|
.ifield label{display:block;font-size:var(--fs-sm);color:var(--ink-2);margin-bottom:.2rem}
|
|
42998
44928
|
.ifield input,.ifield textarea,.ifield select{width:100%;box-sizing:border-box;padding:.5rem .65rem;border:1px solid var(--line);border-radius:var(--radius-sm);font-size:var(--fs-sm);font-family:inherit;background:var(--surface)}
|
|
42999
44929
|
.ifield.prefilled input,.ifield.prefilled textarea{border-color:var(--ok);background:var(--ok-soft)}
|
|
43000
|
-
.ifield.prefilled label:after{content:" \xB7 from
|
|
44930
|
+
.ifield.prefilled label:after{content:" \xB7 from " attr(data-src);color:var(--ok);font-weight:600}
|
|
43001
44931
|
.istatus{font-size:var(--fs-label);font-weight:700;letter-spacing:.04em;padding:.14rem .5rem;border-radius:999px;background:var(--bg-2);color:var(--ink-2);vertical-align:middle}
|
|
43002
44932
|
.istatus.received{background:var(--ok-soft);color:var(--ok)}.istatus.sent{background:var(--gold-soft);color:var(--gold-ink)}
|
|
43003
44933
|
.iq{font-size:1.05rem;color:var(--heading);font-weight:600;margin:.3rem 0 .1rem}
|
|
@@ -43024,10 +44954,11 @@ function html(roster, seatToken) {
|
|
|
43024
44954
|
.stage{min-width:4.2rem}
|
|
43025
44955
|
}
|
|
43026
44956
|
@media (prefers-reduced-motion: reduce){*{transition:none !important}}
|
|
43027
|
-
` })}<body>
|
|
43028
|
-
${cockpitHeaderHtml(
|
|
44957
|
+
` })}<body data-screen="${screen}"${cap ? ` data-capability="${cap.key}"` : ""}>
|
|
44958
|
+
${cockpitHeaderHtml(screen, { capability: cap?.key })}
|
|
43029
44959
|
<main id="main">Loading\u2026</main>
|
|
43030
|
-
<footer>Command OS runs
|
|
44960
|
+
<footer>Command OS runs on this computer, and your client accounts' logins stay on it. What Claude reads or writes for you goes through your own Claude account and to GoHighLevel. A pre-fill from a web address or a call recorder also fetches from that site or that recorder. No copy comes to us.<br>Steps marked "your call" are yours to mark, and your name goes on them. The steps Command OS runs are settled by the run, never by a click.</footer>
|
|
44961
|
+
${cockpitShellClose()}
|
|
43031
44962
|
<script>
|
|
43032
44963
|
/* \u2500\u2500 Seat key bootstrap (PRD row 0) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
43033
44964
|
This page was handed out by the copy of Command OS running on this computer,
|
|
@@ -43039,7 +44970,7 @@ ${cockpitHeaderHtml("board")}
|
|
|
43039
44970
|
every call on the page is covered, including ones added later. Same-origin
|
|
43040
44971
|
only: an absolute or protocol-relative URL goes out untouched, so the key can
|
|
43041
44972
|
never ride along to somebody else's server. */
|
|
43042
|
-
const SEAT_HEADER=${
|
|
44973
|
+
const SEAT_HEADER=${scriptJson(SEAT_TOKEN_HEADER)};const SEAT=${scriptJson(seatToken)};
|
|
43043
44974
|
(function(){var raw=window.fetch.bind(window);
|
|
43044
44975
|
window.fetch=function(input,init){
|
|
43045
44976
|
var u=String(typeof input==="string"?input:(input&&input.url)||"");
|
|
@@ -43048,17 +44979,20 @@ const SEAT_HEADER=${JSON.stringify(SEAT_TOKEN_HEADER)};const SEAT=${JSON.stringi
|
|
|
43048
44979
|
var opts={};for(var k in (init||{}))opts[k]=init[k];
|
|
43049
44980
|
var h=new Headers((init&&init.headers)||{});h.set(SEAT_HEADER,SEAT);opts.headers=h;
|
|
43050
44981
|
return raw(input,opts)};})();
|
|
43051
|
-
const STAGES=${
|
|
43052
|
-
const SANDBOX=${
|
|
43053
|
-
const GATES=${
|
|
44982
|
+
const STAGES=${scriptJson(STAGES2)};const ROSTER=${scriptJson(roster)};
|
|
44983
|
+
const SANDBOX=${scriptJson(Object.keys(SANDBOX_ALLOWLIST))};const POWERED=${scriptJson(Object.keys(STAGE_SPECS).map(Number))};
|
|
44984
|
+
const GATES=${scriptJson(HUMAN_GATE_STAGES)};let PROTECTED=${scriptJson(PROTECTED_LOCATIONS)};
|
|
43054
44985
|
const TYPES=["client","sandbox","template","hidden"];
|
|
43055
44986
|
const TYPE_LABELS={client:"Client",sandbox:"Practice sandbox",template:"Template",hidden:"Hidden"};
|
|
43056
44987
|
const LABELS=["Intake","Brief","Approve","Build","Verify","Manual tasks","Go-live"];
|
|
43057
44988
|
const STATUS_TEXT={pending:"not started",in_progress:"in progress",done:"done"};
|
|
43058
44989
|
const NEXT={pending:"in_progress",in_progress:"done",done:"pending"};
|
|
43059
44990
|
let polling=false,query="",showParked=false,showNew=false,snapshots=null;
|
|
44991
|
+
/* Which of the board's four screens this page is: board (overview), new, queue, clients. */
|
|
44992
|
+
const SCREEN=document.body.getAttribute("data-screen")||"board";
|
|
44993
|
+
const CAP=${scriptJson(capForPage)};const HUB=${scriptJson(HUB_URL)};
|
|
43060
44994
|
const INDUSTRIES=["Med spa","Clinic / practice","Coach / consultant","Local service","Ecommerce","Agency","Other"];
|
|
43061
|
-
const INDUSTRIES2=${
|
|
44995
|
+
const INDUSTRIES2=${scriptJson(INDUSTRY_PACKS.map((p) => ({ slug: p.slug, label: p.label })))};
|
|
43062
44996
|
/* One account at a time (Jerry, 2026-08-26: "way too busy\u2026 tempting to work on multiple
|
|
43063
44997
|
accounts at once"). One card is ever expanded; every other account is a one-line row. */
|
|
43064
44998
|
let activeId=null;try{activeId=localStorage.getItem("cmdos-active")||null}catch(e){}
|
|
@@ -43066,7 +45000,7 @@ function setActive(id){activeId=id;try{localStorage.setItem("cmdos-active",id)}c
|
|
|
43066
45000
|
function initials(name){const p=String(name||"").trim().split(/\\s+/);return ((p[0]||"?")[0]+(p[1]?p[1][0]:"")).toUpperCase()}
|
|
43067
45001
|
function typeOf(state,id){return (state.accounts&&state.accounts[id]&&state.accounts[id].type)||(SANDBOX.includes(id)?"sandbox":"client")}
|
|
43068
45002
|
function activityScore(state,id){const c=state.clients[id];if(!c)return 0;return c.stages.some(s=>s!=="pending")?1:0}
|
|
43069
|
-
async function load(){const s=await(await fetch("/api/state")).json();const r=await(await fetch("/api/run/status")).json();await loadQueue();render(s,r);
|
|
45003
|
+
async function load(){const s=await(await fetch("/api/state")).json();const r=await(await fetch("/api/run/status")).json();await loadQueue();if(SCREEN==="new"&&!snapshots){try{const sr=await(await fetch("/api/snapshots")).json();snapshots=sr.ok?sr.snapshots:[]}catch(e){snapshots=[]}}render(s,r);
|
|
43070
45004
|
if(r.running&&!polling){polling=true;const t=setInterval(async()=>{if(userIsBusy())return;const rr=await(await fetch("/api/run/status")).json();const ss=await(await fetch("/api/state")).json();await loadQueue();render(ss,rr);if(!rr.running){clearInterval(t);polling=false}},1500)}}
|
|
43071
45005
|
// Heartbeat (2026-08-25): the fast poller above only starts for a run THIS page saw begin. A page that was
|
|
43072
45006
|
// already open, or opened mid-run, or a run kicked off from another seat/the API, showed "pending" until a
|
|
@@ -43114,7 +45048,8 @@ function intakePanel(){const d=document.createElement("div");d.className="ncform
|
|
|
43114
45048
|
reset.onclick=()=>{if(confirm("Remove all your customizations and go back to the base question set?"))saveOverlay({})};
|
|
43115
45049
|
d.appendChild(reset);
|
|
43116
45050
|
return d}
|
|
43117
|
-
let iOpen={},iData={},iMode={},iAnswers={},iIdx={},iPre={},iSub={},oOpen={};
|
|
45051
|
+
let iOpen={},iData={},iMode={},iAnswers={},iIdx={},iPre={},iSub={},oOpen={},iDocs={},iPreSrc={};
|
|
45052
|
+
async function iDocsLoad(id){try{const r=await(await fetch("/api/intake-documents?locationId="+encodeURIComponent(id))).json();iDocs[id]=(r&&r.files)||[]}catch(e){iDocs[id]=[]}render(lastState,lastRun)}
|
|
43118
45053
|
// What came back, written for the operator (oc.plain). The raw report the stage produced stays
|
|
43119
45054
|
// behind "Details for the operator" (2026-08-26: "The Verify report was written for you, not them").
|
|
43120
45055
|
function outcomePanel(id,i,state){const oc=(state.outcomes||{})[id+":"+i];if(!oc)return null;
|
|
@@ -43137,7 +45072,7 @@ function outcomePanel(id,i,state){const oc=(state.outcomes||{})[id+":"+i];if(!oc
|
|
|
43137
45072
|
async function iLoad(id){const r=await(await fetch("/api/client-intake?locationId="+id)).json();iData[id]=r;
|
|
43138
45073
|
if(r.ok&&!iAnswers[id]){iAnswers[id]={}}render(lastState,lastRun)}
|
|
43139
45074
|
function iField(id,q,val,prefilled){const w=document.createElement("div");w.className="ifield"+(prefilled?" prefilled":"");
|
|
43140
|
-
const l=document.createElement("label");l.textContent=q.label+(q.required?" *":"");w.appendChild(l);
|
|
45075
|
+
const l=document.createElement("label");l.textContent=q.label+(q.required?" *":"");if(prefilled)l.setAttribute("data-src",iPreSrc[id]||"your call");w.appendChild(l);
|
|
43141
45076
|
let inp;
|
|
43142
45077
|
if(q.multi&&q.options&&q.options.length){
|
|
43143
45078
|
// Several answers allowed (the GHL field is multi-choice) \u2014 checkboxes, stored as a list. (2026-08-25, finding 8)
|
|
@@ -43157,13 +45092,13 @@ function iField(id,q,val,prefilled){const w=document.createElement("div");w.clas
|
|
|
43157
45092
|
w.appendChild(inp);return w}
|
|
43158
45093
|
function intakeWorkspace(id,name){const d=iData[id];const box=document.createElement("div");box.className="ipanel";
|
|
43159
45094
|
if(!d){box.textContent="Loading intake\u2026";return box}
|
|
43160
|
-
if(!d.ok){box.innerHTML="<h3>Intake</h3><p style='font-size:var(--fs-sm);color:var(--warn)'>"+d.error+"</p>";return box}
|
|
43161
|
-
box.innerHTML="<h3>Intake for "+name+"</h3>";
|
|
45095
|
+
if(!d.ok){box.innerHTML="<h3>Intake</h3><p style='font-size:var(--fs-sm);color:var(--warn)'>"+escT(d.error)+"</p>";return box}
|
|
45096
|
+
box.innerHTML="<h3>Intake for "+escT(name)+"</h3>";
|
|
43162
45097
|
const sub=document.createElement("p");sub.className="small muted";sub.style.margin=".1rem 0 .2rem";
|
|
43163
45098
|
sub.textContent=d.status==="received"?((d.channel==="form"?"Submitted through the GHL form":"Saved from Command OS")+(d.answeredAt?" \xB7 "+new Date(d.answeredAt).toLocaleString():"")):d.status==="sent"?"Link sent \u2014 waiting on the client":"Ready to complete";
|
|
43164
45099
|
box.appendChild(sub);
|
|
43165
45100
|
const ch=document.createElement("div");ch.className="ichoice";
|
|
43166
|
-
const modes=[["fill","Fill it here"],["interview","Interview mode"],["transcript","From a call"],["send","Send to client"],["link","Copy link"]];
|
|
45101
|
+
const modes=[["fill","Fill it here"],["interview","Interview mode"],["transcript","From a call or a document"],["send","Send to client"],["link","Copy link"]];
|
|
43167
45102
|
for(const [k,label] of modes){const b=document.createElement("button");b.textContent=label;if(iMode[id]===k)b.className="on";
|
|
43168
45103
|
b.onclick=async()=>{if(k==="link"){try{await navigator.clipboard.writeText(d.formUrl);b.textContent="Copied \u2713";setTimeout(()=>render(lastState,lastRun),1200)}catch(e){prompt("Copy this link:",d.formUrl)}return}
|
|
43169
45104
|
iMode[id]=iMode[id]===k?null:k;iIdx[id]=0;render(lastState,lastRun)};
|
|
@@ -43184,7 +45119,7 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
43184
45119
|
if(mode==="transcript"){const w=document.createElement("div");
|
|
43185
45120
|
const tabs=document.createElement("div");tabs.className="ichoice";tabs.style.margin=".2rem 0 .6rem";
|
|
43186
45121
|
iSub[id]=iSub[id]||"paste";
|
|
43187
|
-
for(const [k,lab] of [["paste","Paste
|
|
45122
|
+
for(const [k,lab] of [["paste","Paste a transcript"],["doc","A document (PDF, Word, text)"],["url","From a link"],["recorder","From my recorder"]]){
|
|
43188
45123
|
const tb=document.createElement("button");tb.textContent=lab;if(iSub[id]===k)tb.className="on";
|
|
43189
45124
|
tb.onclick=()=>{iSub[id]=k;render(lastState,lastRun)};tabs.appendChild(tb)}
|
|
43190
45125
|
w.appendChild(tabs);
|
|
@@ -43201,13 +45136,13 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
43201
45136
|
else if(sub==="recorder"){if(!recIn.value.trim())return alert("Tell it which recording to grab");payload.instruction=recIn.value.trim()}
|
|
43202
45137
|
else {if(!ta.value.trim())return alert("Paste or drop the transcript first");payload.transcript=ta.value}
|
|
43203
45138
|
b.disabled=true;b.textContent="Reading the call\u2026";
|
|
43204
|
-
st.textContent=sub==="paste"?"
|
|
43205
|
-
:sub==="url"?"Your own Claude
|
|
43206
|
-
:"Your own Claude
|
|
45139
|
+
st.textContent=sub==="paste"?"This text goes to your own Claude account to be read, and nowhere else."
|
|
45140
|
+
:sub==="url"?"Your own Claude fetches that link, so that site sees the request; the page text then goes to your Claude account and nowhere else."
|
|
45141
|
+
:"Your own Claude asks the recorder you connected (Plaud, Otter, Fireflies\u2026) for that call; the transcript then goes to your Claude account and nowhere else.";
|
|
43207
45142
|
const r=await(await fetch("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)})).json();
|
|
43208
45143
|
b.disabled=false;b.textContent="Fill the intake from this";
|
|
43209
45144
|
if(!r.ok){st.textContent="Could not read that: "+(r.error||"unknown");return}
|
|
43210
|
-
iAnswers[id]=Object.assign({},iAnswers[id],r.answers);iPre[id]=Object.keys(r.answers);
|
|
45145
|
+
iAnswers[id]=Object.assign({},iAnswers[id],r.answers);iPre[id]=Object.keys(r.answers);iPreSrc[id]=sub==="url"?"the link":"your call";
|
|
43211
45146
|
iMode[id]="fill";render(lastState,lastRun)};
|
|
43212
45147
|
const urlIn=document.createElement("input");urlIn.placeholder="https://\u2026 a shared transcript or recording page";
|
|
43213
45148
|
urlIn.style.cssText="width:100%;box-sizing:border-box;padding:.6rem;border:1px solid var(--line);border-radius:7px;font-size:var(--fs-sm);background:var(--surface)";
|
|
@@ -43215,13 +45150,45 @@ function intakeWorkspace(id,name){const d=iData[id];const box=document.createEle
|
|
|
43215
45150
|
recIn.style.cssText=urlIn.style.cssText;
|
|
43216
45151
|
const hint=document.createElement("p");hint.className="small muted";hint.style.margin=".4rem 0 0";
|
|
43217
45152
|
if(iSub[id]==="paste"){w.appendChild(ta)}
|
|
45153
|
+
else if(iSub[id]==="doc"){
|
|
45154
|
+
/* A document the client sent: PDF, Word or text. Stored with the client on this
|
|
45155
|
+
computer; your own Claude reads it here and fills the intake from it. */
|
|
45156
|
+
const docs=iDocs[id];
|
|
45157
|
+
if(docs===undefined){iDocs[id]=[];iDocsLoad(id)}
|
|
45158
|
+
const drop=document.createElement("div");drop.className="idrop";
|
|
45159
|
+
drop.textContent="Drop a PDF, Word or text file here, or choose one";
|
|
45160
|
+
const fi=document.createElement("input");fi.type="file";fi.accept=".pdf,.docx,.doc,.md,.txt,.rtf,.csv";fi.style.display="block";fi.style.marginTop=".5rem";
|
|
45161
|
+
const up=async(f)=>{if(!f)return;if(f.size>20*1024*1024){alert("That file is over 20 MB. Split it or export the part that matters.");return}
|
|
45162
|
+
drop.textContent="Saving "+f.name+"\u2026";
|
|
45163
|
+
const b64=await new Promise((res,rej)=>{const rd=new FileReader();rd.onload=()=>res(String(rd.result).split(",")[1]||"");rd.onerror=rej;rd.readAsDataURL(f)});
|
|
45164
|
+
const r=await(await fetch("/api/intake-document",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,name:f.name,base64:b64})})).json();
|
|
45165
|
+
if(!r.ok){alert(r.error||"Could not save that file");drop.textContent="Drop a PDF, Word or text file here, or choose one";return}
|
|
45166
|
+
iDocs[id]=r.files||[];render(lastState,lastRun)};
|
|
45167
|
+
drop.ondragover=(e)=>{e.preventDefault();drop.style.borderColor="var(--gold)"};
|
|
45168
|
+
drop.ondragleave=()=>{drop.style.borderColor="var(--line)"};
|
|
45169
|
+
drop.ondrop=(e)=>{e.preventDefault();drop.style.borderColor="var(--line)";up(e.dataTransfer.files[0])};
|
|
45170
|
+
fi.onchange=()=>up(fi.files[0]);
|
|
45171
|
+
drop.appendChild(fi);w.appendChild(drop);
|
|
45172
|
+
const list=document.createElement("div");list.className="idocs";
|
|
45173
|
+
if(!docs||!docs.length){const p=document.createElement("p");p.className="small muted";p.style.margin=".5rem 0 0";p.textContent="No documents saved for this client yet.";list.appendChild(p)}
|
|
45174
|
+
for(const f of (docs||[])){const row=document.createElement("div");row.className="idoc";
|
|
45175
|
+
const nm=document.createElement("span");nm.textContent=f.name+" \xB7 "+Math.max(1,Math.round(f.bytes/1024))+" KB";row.appendChild(nm);
|
|
45176
|
+
const rb=document.createElement("button");rb.className="intakebtn";rb.textContent="Fill the intake from this";
|
|
45177
|
+
rb.onclick=async()=>{rb.disabled=true;rb.textContent="Reading it\u2026";st.textContent="The text of "+f.name+" goes to your own Claude account to be read, and nowhere else.";
|
|
45178
|
+
const r=await(await fetch("/api/intake-prefill",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:id,labels:d.questions.map(q=>q.label),file:f.name})})).json();
|
|
45179
|
+
rb.disabled=false;rb.textContent="Fill the intake from this";
|
|
45180
|
+
if(!r.ok){st.textContent="Could not read that: "+(r.error||"unknown");return}
|
|
45181
|
+
iAnswers[id]=Object.assign({},iAnswers[id],r.answers);iPre[id]=Object.keys(r.answers);iPreSrc[id]="the document";iMode[id]="fill";render(lastState,lastRun)};
|
|
45182
|
+
row.appendChild(rb);list.appendChild(row)}
|
|
45183
|
+
w.appendChild(list);
|
|
45184
|
+
hint.textContent="Stored with this client on this computer. When you press the button, the document's text goes to your own Claude account to be read, and nowhere else. Only what it does not answer is left for you.";w.appendChild(hint);w.appendChild(st)}
|
|
43218
45185
|
else if(iSub[id]==="url"){w.appendChild(urlIn);hint.textContent="Works for links that don't need a login. Behind a login? Use \\u201cFrom my recorder\\u201d.";w.appendChild(hint)}
|
|
43219
45186
|
else {w.appendChild(recIn);hint.textContent="Uses the recorder you already have connected to Claude (Plaud, Otter, Fireflies, Zoom). Nothing new to set up if it's already there.";w.appendChild(hint)}
|
|
43220
|
-
w.appendChild(b);w.appendChild(st)
|
|
45187
|
+
if(iSub[id]!=="doc"){w.appendChild(b);w.appendChild(st)}box.appendChild(w)}
|
|
43221
45188
|
if(mode==="fill"||mode==="interview"){
|
|
43222
45189
|
const qs=d.questions;const ans=iAnswers[id]||{};const pre=iPre[id]||[];
|
|
43223
45190
|
if(pre.length&&mode==="fill"){const banner=document.createElement("div");banner.className="pendpanel";
|
|
43224
|
-
banner.textContent="\u2713 "+pre.length+" answers pre-filled from your call. Correct anything wrong; "+(qs.length-pre.length)+" still need you.";box.appendChild(banner)}
|
|
45191
|
+
banner.textContent="\u2713 "+pre.length+" answers pre-filled from "+(iPreSrc[id]||"your call")+". Correct anything wrong; "+(qs.length-pre.length)+" still need you.";box.appendChild(banner)}
|
|
43225
45192
|
if(mode==="interview"){const i=Math.min(iIdx[id]||0,qs.length-1);const q=qs[i];
|
|
43226
45193
|
const prog=document.createElement("p");prog.className="small muted";prog.style.margin=".2rem 0";prog.textContent="Question "+(i+1)+" of "+qs.length+" \xB7 "+q.sectionLabel;
|
|
43227
45194
|
const qq=document.createElement("div");qq.className="iq";qq.textContent=q.label;
|
|
@@ -43272,8 +45239,10 @@ async function rLoad(id){const r=await(await fetch("/api/review?locationId="+id)
|
|
|
43272
45239
|
// which wiped the button's "Thinking it through\u2026" \u2014 a click looked like nothing happened. The in-flight flag
|
|
43273
45240
|
// lives outside the card so re-renders keep it; a failure is shown in the panel instead of vanishing.
|
|
43274
45241
|
function reviewBusy(id,run){return Boolean(rBusy[id])||Boolean(run&&run.running&&run.reviewing&&run.locationId===id)}
|
|
45242
|
+
/* The review is written by the model from the client's intake answers, so every value in it is untrusted text (Codex, round 2). */
|
|
45243
|
+
function escT(s){return String(s==null?"":s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}
|
|
43275
45244
|
function reviewPanel(id,name,state,run){const r=rData[id];const d=document.createElement("div");d.className="review";
|
|
43276
|
-
d.innerHTML="<h3>Blueprint Review \u2014 "+name+"</h3>";
|
|
45245
|
+
d.innerHTML="<h3>Blueprint Review \u2014 "+escT(name)+"</h3>";
|
|
43277
45246
|
const busy=reviewBusy(id,run);
|
|
43278
45247
|
const gen=document.createElement("button");gen.className="runbtn";gen.style.marginTop=".4rem";
|
|
43279
45248
|
gen.textContent=busy?"Thinking it through\u2026":(r&&r.ok)?"Regenerate":"Generate the review";gen.disabled=busy;
|
|
@@ -43290,24 +45259,24 @@ function reviewPanel(id,name,state,run){const r=rData[id];const d=document.creat
|
|
|
43290
45259
|
if(r.headline){const h=document.createElement("div");h.className="headline";h.textContent=r.headline;d.appendChild(h)}
|
|
43291
45260
|
if(r.ica){const s1=document.createElement("div");s1.innerHTML="<h4>Who they actually sell to</h4>";
|
|
43292
45261
|
const p1=document.createElement("p");p1.textContent=r.ica.who||"";s1.appendChild(p1);
|
|
43293
|
-
if((r.ica.painPoints||[]).length){const p2=document.createElement("p");p2.innerHTML="<b>Pain:</b> "+r.ica.painPoints.join(" \xB7 ");s1.appendChild(p2)}
|
|
43294
|
-
if((r.ica.objections||[]).length){const p3=document.createElement("p");p3.innerHTML="<b>Objections:</b> "+r.ica.objections.join(" \xB7 ");s1.appendChild(p3)}
|
|
43295
|
-
if(r.ica.buyingTrigger){const p4=document.createElement("p");p4.innerHTML="<b>Buys when:</b> "+r.ica.buyingTrigger;s1.appendChild(p4)}
|
|
43296
|
-
if(r.ica.whereTheyAre){const p5=document.createElement("p");p5.innerHTML="<b>Found at:</b> "+r.ica.whereTheyAre;s1.appendChild(p5)}
|
|
45262
|
+
if((r.ica.painPoints||[]).length){const p2=document.createElement("p");p2.innerHTML="<b>Pain:</b> "+r.ica.painPoints.map(escT).join(" \xB7 ");s1.appendChild(p2)}
|
|
45263
|
+
if((r.ica.objections||[]).length){const p3=document.createElement("p");p3.innerHTML="<b>Objections:</b> "+r.ica.objections.map(escT).join(" \xB7 ");s1.appendChild(p3)}
|
|
45264
|
+
if(r.ica.buyingTrigger){const p4=document.createElement("p");p4.innerHTML="<b>Buys when:</b> "+escT(r.ica.buyingTrigger);s1.appendChild(p4)}
|
|
45265
|
+
if(r.ica.whereTheyAre){const p5=document.createElement("p");p5.innerHTML="<b>Found at:</b> "+escT(r.ica.whereTheyAre);s1.appendChild(p5)}
|
|
43297
45266
|
d.appendChild(s1)}
|
|
43298
|
-
if(r.offer){const s2=document.createElement("div");s2.innerHTML='<h4>The offer <span class="score">'+(r.offer.score||0)+'/10</span></h4>';
|
|
45267
|
+
if(r.offer){const s2=document.createElement("div");s2.innerHTML='<h4>The offer <span class="score">'+(Number(r.offer.score)||0)+'/10</span></h4>';
|
|
43299
45268
|
for(const [k,lab] of [["dreamOutcome","Worth wanting"],["believability","Believable"],["speed","Speed to result"],["effort","Effort asked"]]){
|
|
43300
|
-
if(r.offer[k]){const p=document.createElement("p");p.innerHTML="<b>"+lab+":</b> "+r.offer[k];s2.appendChild(p)}}
|
|
43301
|
-
if(r.offer.biggestLever){const p=document.createElement("p");p.innerHTML="<b>Biggest lever:</b> "+r.offer.biggestLever;s2.appendChild(p)}
|
|
45269
|
+
if(r.offer[k]){const p=document.createElement("p");p.innerHTML="<b>"+escT(lab)+":</b> "+escT(r.offer[k]);s2.appendChild(p)}}
|
|
45270
|
+
if(r.offer.biggestLever){const p=document.createElement("p");p.innerHTML="<b>Biggest lever:</b> "+escT(r.offer.biggestLever);s2.appendChild(p)}
|
|
43302
45271
|
if(r.offer.recommendedOffer){const p=document.createElement("p");p.className="headline";p.textContent="Run this instead: "+r.offer.recommendedOffer;s2.appendChild(p)}
|
|
43303
45272
|
d.appendChild(s2)}
|
|
43304
|
-
if((r.gaps||[]).length){const s3=document.createElement("div");s3.innerHTML="<h4>What's missing ("+r.gaps.length+")</h4>";
|
|
45273
|
+
if((r.gaps||[]).length){const s3=document.createElement("div");s3.innerHTML="<h4>What's missing ("+(Number(r.gaps.length)||0)+")</h4>";
|
|
43305
45274
|
for(const g of r.gaps){const gg=document.createElement("div");gg.className="gap "+(g.priority||"soon");
|
|
43306
|
-
gg.innerHTML="<b>"+g.title+"</b><span>"+(g.cost?g.cost+" \u2014 ":"")+(g.fix||"")+"</span>";s3.appendChild(gg)}
|
|
45275
|
+
gg.innerHTML="<b>"+escT(g.title)+"</b><span>"+(g.cost?escT(g.cost)+" \u2014 ":"")+escT(g.fix||"")+"</span>";s3.appendChild(gg)}
|
|
43307
45276
|
d.appendChild(s3)}
|
|
43308
45277
|
if((r.build||[]).length){const s4=document.createElement("div");s4.innerHTML="<h4>What we build</h4>";
|
|
43309
45278
|
for(const b of r.build){const bb=document.createElement("div");bb.className="buildline";
|
|
43310
|
-
bb.innerHTML=b.item+"<em>"+(b.because||"")+"</em>";s4.appendChild(bb)}
|
|
45279
|
+
bb.innerHTML=escT(b.item)+"<em>"+escT(b.because||"")+"</em>";s4.appendChild(bb)}
|
|
43311
45280
|
d.appendChild(s4)}
|
|
43312
45281
|
if((r.unknowns||[]).length){const s5=document.createElement("div");s5.innerHTML="<h4>Ask them next</h4>";
|
|
43313
45282
|
const p=document.createElement("p");p.textContent=r.unknowns.join(" \xB7 ");s5.appendChild(p);d.appendChild(s5)}
|
|
@@ -43340,7 +45309,7 @@ function pendingCard(c,state){const div=document.createElement("div");div.classN
|
|
|
43340
45309
|
div.appendChild(head);
|
|
43341
45310
|
const p=document.createElement("div");p.className="pendpanel";
|
|
43342
45311
|
p.innerHTML="<strong>Account created \u2014 one 2-minute step left.</strong> GHL requires a key made inside the new sub-account:"+
|
|
43343
|
-
"<ol><li>Open the new sub-account in GHL (switch into <em>"+c.name+"</em>)</li>"+
|
|
45312
|
+
"<ol><li>Open the new sub-account in GHL (switch into <em>"+escT(c.name)+"</em>)</li>"+
|
|
43344
45313
|
"<li>Settings \u2192 Private Integrations \u2192 Create New Integration \u2192 name it anything \u2192 Scopes: <strong>Select all</strong> \u2192 Create \u2192 Confirm</li>"+
|
|
43345
45314
|
"<li>Copy the <code>pit-</code> key and paste it below</li></ol>";
|
|
43346
45315
|
const inp=document.createElement("input");inp.placeholder="pit-\u2026";p.appendChild(inp);
|
|
@@ -43600,7 +45569,7 @@ function proofLine(p){const d=document.createElement("div");
|
|
|
43600
45569
|
const when=new Date(p.at).toLocaleDateString(undefined,{month:"short",day:"numeric"});
|
|
43601
45570
|
d.textContent=p.step+" \u2014 "+(bad?(p.issues.length+" to sort out: "+shorten(p.issues[0],120)):shorten(p.summary,150))+" \xB7 "+when;
|
|
43602
45571
|
return d}
|
|
43603
|
-
function queueOpen(id){setActive(id);try{window.scrollTo({top:0,behavior:"smooth"})}catch(e){}}
|
|
45572
|
+
function queueOpen(id){activeId=id;try{localStorage.setItem("cmdos-active",id)}catch(e){};if(SCREEN!=="clients"){location.href="/clients";return}setActive(id);try{window.scrollTo({top:0,behavior:"smooth"})}catch(e){}}
|
|
43604
45573
|
function queueRow(it){const row=document.createElement("div");row.className="q-row";
|
|
43605
45574
|
row.setAttribute("role","button");row.tabIndex=0;
|
|
43606
45575
|
const pill=document.createElement("span");pill.className=it.needs==="you"?"state warn":"state live";
|
|
@@ -43643,7 +45612,8 @@ function queueBlock(match){const q=queueData;if(!q||!q.ok)return null;
|
|
|
43643
45612
|
const live=items.filter(i=>i.rank<6),later=items.filter(i=>i.rank>=6);
|
|
43644
45613
|
const fin=(q.finished||[]).filter(f=>match({name:f.clientName,locationId:f.locationId}));
|
|
43645
45614
|
const head=document.createElement("div");head.className="q-head";
|
|
43646
|
-
|
|
45615
|
+
head.id="queue";
|
|
45616
|
+
const eb=document.createElement("div");eb.className="eyebrow";eb.textContent="Waiting on you";head.appendChild(eb);
|
|
43647
45617
|
if(q.counts&&q.counts.you){const c=document.createElement("span");c.className="chip space";
|
|
43648
45618
|
c.textContent=q.counts.you+" waiting on you";head.appendChild(c)}
|
|
43649
45619
|
const who=document.createElement("span");who.className="q-who";
|
|
@@ -43679,31 +45649,114 @@ function syncReviews(state){const revs=(state&&state.reviews)||{};
|
|
|
43679
45649
|
rData[id]=rv;
|
|
43680
45650
|
if(!lastState){rSeen[id]=true}
|
|
43681
45651
|
else if(rv&&rv.ok&&!had&&briefDone&&!rSeen[id]){rOpen[id]=true;rSeen[id]=true}}}
|
|
45652
|
+
/* \u2500\u2500 One capability, for the open client (Jerry, 2026-09-05) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
45653
|
+
let capReads=null,capReadsFor="",capRun=null,capPoll=null;
|
|
45654
|
+
function capClients(state){return ROSTER.filter(c=>["client","sandbox"].includes(typeOf(state,c.locationId)))}
|
|
45655
|
+
function capActive(state){const cs=capClients(state);let act=(activeId&&cs.some(c=>c.locationId===activeId))?activeId:null;if(!act&&cs.length)act=cs[0].locationId;return cs.find(c=>c.locationId===act)||null}
|
|
45656
|
+
async function loadCapReads(id){capReadsFor=id;capReads=null;try{capReads=await(await fetch("/api/capability?key="+encodeURIComponent(CAP.key)+"&locationId="+encodeURIComponent(id))).json()}catch(e){capReads={ok:false,error:String(e)}}if(lastState)render(lastState,lastRun)}
|
|
45657
|
+
function renderCapability(m,state,run){
|
|
45658
|
+
const c=capActive(state);
|
|
45659
|
+
const head=document.createElement("div");head.className="eyebrow";head.textContent=CAP.groupLabel;m.appendChild(head);
|
|
45660
|
+
m.appendChild(screenHead(CAP.label,CAP.what));
|
|
45661
|
+
if(!c){const d=document.createElement("div");d.className="client";d.innerHTML="<h2>No client account is open</h2><p style='margin-top:.4rem'>Register one in GHL Command or <a href='/new'>build a client account</a>, then come back here.</p>";m.appendChild(d);return}
|
|
45662
|
+
/* Who this is for */
|
|
45663
|
+
const who=document.createElement("div");who.className="toolbar";
|
|
45664
|
+
const lbl=document.createElement("span");lbl.className="q-who";lbl.textContent="For ";who.appendChild(lbl);
|
|
45665
|
+
const sel=document.createElement("select");sel.className="capsel";for(const cl of capClients(state)){const o=document.createElement("option");o.value=cl.locationId;o.textContent=cl.name;if(cl.locationId===c.locationId)o.selected=true;sel.appendChild(o)}
|
|
45666
|
+
sel.onchange=()=>{capReads=null;capReadsFor="";setActive(sel.value)};who.appendChild(sel);m.appendChild(who);
|
|
45667
|
+
const sc=document.getElementById("side-client");if(sc)sc.textContent=c.name;
|
|
45668
|
+
/* Already in this account */
|
|
45669
|
+
const eb=document.createElement("div");eb.className="eyebrow";eb.textContent="Already in this account";m.appendChild(eb);
|
|
45670
|
+
const card=document.createElement("div");card.className="card pad cap-reads";
|
|
45671
|
+
if(!CAP.reads.length){card.innerHTML="<p class='muted'>Nothing here is read back yet; the page cannot tell you what this account already has for this job. Ask Claude with the words below and it reads it live.</p>"}
|
|
45672
|
+
else if(capReadsFor!==c.locationId){card.innerHTML="<p class='muted'>Reading this account\u2026</p>";loadCapReads(c.locationId)}
|
|
45673
|
+
else if(!capReads||!capReads.ok){card.innerHTML="<p class='muted'></p>";card.querySelector("p").textContent=(capReads&&capReads.error)?capReads.error:"Could not read this account."}
|
|
45674
|
+
else{for(const r of capReads.reads){const row=document.createElement("div");row.className="cap-row";
|
|
45675
|
+
const n=document.createElement("b");n.textContent=r.status==="ok"?(r.count+" "+r.label):r.label;row.appendChild(n);
|
|
45676
|
+
const t=document.createElement("span");t.className="muted";t.textContent=r.status==="ok"?(r.names.length?": "+r.names.slice(0,8).join(" \xB7 ")+(r.names.length>8?" \u2026":""):(r.count?"":": none yet")):(" \u2014 could not read: "+(r.reason||"unavailable"));row.appendChild(t);card.appendChild(row)}
|
|
45677
|
+
if(CAP.blueprint){const bp=document.createElement("p");bp.className="muted";bp.style.marginTop=".6rem";bp.textContent="A full Blueprint build sets up: "+CAP.blueprint;card.appendChild(bp)}}
|
|
45678
|
+
m.appendChild(card);
|
|
45679
|
+
/* Do it now */
|
|
45680
|
+
const eb2=document.createElement("div");eb2.className="eyebrow";eb2.textContent="Do it now";m.appendChild(eb2);
|
|
45681
|
+
const doc=document.createElement("div");doc.className="card pad";
|
|
45682
|
+
const words=CAP.says.split("{client}").join(c.name);
|
|
45683
|
+
const q=document.createElement("blockquote");q.className="cap-words";q.textContent=words;doc.appendChild(q);
|
|
45684
|
+
const row=document.createElement("div");row.className="row wrap";row.style.gap=".6rem";row.style.marginTop=".7rem";
|
|
45685
|
+
const bb=document.createElement("button");bb.className="runbtn";bb.textContent="Build this for "+c.name;
|
|
45686
|
+
const busy=run&&run.running;if(busy)bb.disabled=true;
|
|
45687
|
+
if(CAP.noBuild){bb.disabled=true;bb.title="This one messages a real person, so it runs from your own Claude with you watching."}
|
|
45688
|
+
bb.onclick=async()=>{bb.disabled=true;bb.textContent="Starting\u2026";
|
|
45689
|
+
const r=await(await fetch("/api/run-capability",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({locationId:c.locationId,key:CAP.key})})).json();
|
|
45690
|
+
if(!r.ok){alert(r.error||"Could not start");bb.disabled=false;bb.textContent="Build this for "+c.name;return}
|
|
45691
|
+
capRun={locationId:c.locationId};pollCap()};
|
|
45692
|
+
row.appendChild(bb);
|
|
45693
|
+
const cp=document.createElement("button");cp.className="intakebtn";cp.textContent="Copy the words";cp.onclick=async()=>{try{await navigator.clipboard.writeText(words);cp.textContent="Copied"}catch(e){cp.textContent="Select and copy the text above"}setTimeout(()=>{cp.textContent="Copy the words"},2000)};row.appendChild(cp);
|
|
45694
|
+
if(CAP.hub){const h=document.createElement("a");h.className="intakebtn";h.href=HUB+CAP.hub;h.target="_blank";h.rel="noopener";h.textContent="Step by step in the hub \u2197";row.appendChild(h)}
|
|
45695
|
+
doc.appendChild(row);
|
|
45696
|
+
const note=document.createElement("p");note.className="muted small";note.style.marginTop=".6rem";
|
|
45697
|
+
note.textContent=CAP.noBuild?"No button for this one on purpose: it messages a real person. Paste the words into your own Claude and watch it work.":busy?"Another run is in progress. This button wakes up when it finishes.":"Build this runs through your own Claude account, with only the tools this job needs: the words above and what it reads from this account go to Claude, and nowhere else. Workflows are left as drafts; nothing is sent, posted or deleted; every id is read from this account first. Or paste the words into your own Claude and watch it work.";
|
|
45698
|
+
doc.appendChild(note);
|
|
45699
|
+
/* The run, live */
|
|
45700
|
+
if(run&&run.capability===CAP.key&&run.locationId===c.locationId){const pre=document.createElement("pre");pre.className="cap-log";pre.textContent=(run.lines||[]).slice(-40).join(String.fromCharCode(10))||"Starting\u2026";doc.appendChild(pre);
|
|
45701
|
+
if(run.running&&!capPoll)pollCap()}
|
|
45702
|
+
const last=(state.capabilityRuns||{})[c.locationId+":"+CAP.key];
|
|
45703
|
+
if(last){const l=document.createElement("p");l.className="muted small";l.style.marginTop=".5rem";l.textContent=(last.ok?"Last built ":"Last attempt ")+new Date(last.at).toLocaleString()+": "+(last.ok?"done. ":"did not finish. "+(last.error||"")+" ")+(last.summary||"")+(last.issues&&last.issues.length?" Waiting on a person: "+last.issues.join("; "):"");doc.appendChild(l)}
|
|
45704
|
+
m.appendChild(doc)}
|
|
45705
|
+
function pollCap(){if(capPoll)return;capPoll=setInterval(async()=>{try{const r=await(await fetch("/api/run/status")).json();const s=await(await fetch("/api/state")).json();if(!r.running){clearInterval(capPoll);capPoll=null;capRun=null;capReads=null;capReadsFor=""}render(s,r)}catch(e){}},2000)}
|
|
45706
|
+
|
|
45707
|
+
function screenHead(title,sub){const d=document.createElement("div");d.className="screen-head";d.innerHTML="<h1></h1><p class='lead'></p>";d.querySelector("h1").textContent=title;d.querySelector("p").textContent=sub;return d}
|
|
45708
|
+
/* The board's overview: where the work is waiting, in numbers, each one a door to its own page. */
|
|
45709
|
+
function homeBlock(state,run){const wrap=document.createElement("div");
|
|
45710
|
+
wrap.appendChild(screenHead("Command OS","Everything here runs on this computer. Pick up where the work is waiting."));
|
|
45711
|
+
const grid=document.createElement("div");grid.className="home";
|
|
45712
|
+
const q=queueData&&queueData.ok?queueData:null;const you=q&&q.counts?(q.counts.you||0):0;const total=q?(q.items||[]).length:0;
|
|
45713
|
+
const clients=ROSTER.filter(c=>["client","sandbox"].includes(typeOf(state,c.locationId)));
|
|
45714
|
+
let act=(activeId&&clients.some(c=>c.locationId===activeId))?activeId:null;
|
|
45715
|
+
if(!act&&run&&run.running&&clients.some(c=>c.locationId===run.locationId))act=run.locationId;if(!act&&clients.length)act=clients[0].locationId;
|
|
45716
|
+
const actC=clients.find(c=>c.locationId===act);
|
|
45717
|
+
let stage="";if(actC){const st=((state.clients||{})[actC.locationId]||{}).stages||[];const i=st.findIndex(s=>s!=="done");
|
|
45718
|
+
stage=i<0?"Every stage is done":((run&&run.running&&run.locationId===actC.locationId)?"Building now: ":"Next: ")+(LABELS[i]||"")}
|
|
45719
|
+
const tile=(eyebrow,big,text,href,cta)=>{const c=document.createElement("a");c.className="card tile";c.href=href;
|
|
45720
|
+
c.innerHTML="<div class='eyebrow'></div><div class='big'></div><p></p><span class='go'></span>";
|
|
45721
|
+
c.querySelector(".eyebrow").textContent=eyebrow;c.querySelector(".big").textContent=big;c.querySelector("p").textContent=text;c.querySelector(".go").textContent=cta;return c};
|
|
45722
|
+
grid.appendChild(tile("Waiting on you",String(you),total?(total+" step"+(total===1?"":"s")+" in the queue, "+you+" need"+(you===1?"s":"")+" your hand"):"Nothing is waiting on you","/queue","Open the work queue \u2192"));
|
|
45723
|
+
grid.appendChild(tile("Working on now",actC?actC.name:"\u2014",actC?stage:"No client account is open","/clients","Open client builds \u2192"));
|
|
45724
|
+
grid.appendChild(tile("Client accounts",String(clients.length),clients.length===1?"One account on the board":"Accounts on the board, templates and hidden ones aside","/clients","See every account \u2192"));
|
|
45725
|
+
grid.appendChild(tile("New client","+","Register the sub-account, then answer the intake","/new","Build a client account \u2192"));
|
|
45726
|
+
wrap.appendChild(grid);return wrap}
|
|
43682
45727
|
function render(state,run){syncReviews(state);lastState=state;lastRun=run;
|
|
43683
45728
|
if(state&&state.protectedAccounts)PROTECTED=state.protectedAccounts;const m=document.getElementById("main");m.innerHTML="";
|
|
45729
|
+
/* Four screens share this page's data and differ only in what they show. */
|
|
45730
|
+
if(SCREEN==="new"){m.appendChild(screenHead("Build a client account","Register the sub-account, then answer the intake. The seven stages run from Client Builds."));
|
|
45731
|
+
m.appendChild(newClientForm());
|
|
45732
|
+
/* Step 2 on the same page: the intake for the client that is open, with the
|
|
45733
|
+
document, link, transcript and recorder ways in (Jerry, 2026-09-05). */
|
|
45734
|
+
const c0=capActive(state);
|
|
45735
|
+
if(c0){const eb2=document.createElement("div");eb2.className="eyebrow";eb2.style.marginTop="1.4rem";eb2.textContent="Step 2 \u2014 the intake for "+c0.name;m.appendChild(eb2);
|
|
45736
|
+
const pick=document.createElement("p");pick.className="small muted";pick.style.margin="0 0 .6rem";pick.textContent="A different client? Open it under Client Builds first.";m.appendChild(pick);
|
|
45737
|
+
if(!iData[c0.locationId])iLoad(c0.locationId);
|
|
45738
|
+
m.appendChild(intakeWorkspace(c0.locationId,c0.name))}
|
|
45739
|
+
const ib=document.createElement("button");ib.className="intakebtn";ib.style.marginTop="1rem";ib.textContent=intakeOpen?"\xD7 Close the intake questions":"Intake questions";
|
|
45740
|
+
ib.onclick=async()=>{intakeOpen=!intakeOpen;if(intakeOpen&&!intakeData)await loadIntake();else render(state,run)};m.appendChild(ib);
|
|
45741
|
+
if(intakeOpen)m.appendChild(intakePanel());return}
|
|
45742
|
+
if(SCREEN==="for"&&CAP){renderCapability(m,state,run);return}
|
|
43684
45743
|
if(!ROSTER.length){const d=document.createElement("div");d.className="client";
|
|
43685
|
-
d.innerHTML="<h2>No clients yet</h2><p style='margin-top:.4rem'>Register a sub-account in GHL Command and it appears here
|
|
45744
|
+
d.innerHTML="<h2>No clients yet</h2><p style='margin-top:.4rem'>Register a sub-account in GHL Command and it appears here, or <a href='/new'>build a client account</a>.</p>";m.appendChild(d);return}
|
|
45745
|
+
if(SCREEN==="board"){m.appendChild(homeBlock(state,run));return}
|
|
43686
45746
|
const bar=document.createElement("div");bar.className="toolbar";
|
|
43687
45747
|
const inp=document.createElement("input");inp.placeholder="Find a client\u2026";inp.value=query;
|
|
43688
45748
|
inp.oninput=()=>{query=inp.value;render(state,run);const i2=document.querySelector(".toolbar input");i2.focus();i2.setSelectionRange(query.length,query.length)};
|
|
43689
45749
|
bar.appendChild(inp);
|
|
43690
|
-
|
|
43691
|
-
nb.
|
|
43692
|
-
|
|
43693
|
-
|
|
43694
|
-
|
|
43695
|
-
|
|
43696
|
-
|
|
43697
|
-
sy.textContent=r.adopted?("\u27F3 "+r.adopted+" updated"):"\u27F3 Up to date";setTimeout(()=>{sy.textContent="\u27F3 Sync"},2500);load()};
|
|
43698
|
-
bar.appendChild(sy);
|
|
43699
|
-
const ib=document.createElement("button");ib.className="intakebtn";ib.textContent=intakeOpen?"\xD7 Intake":"Intake questions";
|
|
43700
|
-
ib.onclick=async()=>{intakeOpen=!intakeOpen;if(intakeOpen&&!intakeData)await loadIntake();else render(state,run)};
|
|
43701
|
-
bar.appendChild(ib);m.appendChild(bar);
|
|
43702
|
-
if(showNew)m.appendChild(newClientForm());
|
|
43703
|
-
if(intakeOpen)m.appendChild(intakePanel());
|
|
45750
|
+
if(SCREEN==="clients"){
|
|
45751
|
+
const nb=document.createElement("a");nb.className="newbtn";nb.href="/new";nb.textContent="\uFF0B New Client";bar.appendChild(nb);
|
|
45752
|
+
const sy=document.createElement("button");sy.className="intakebtn";sy.textContent="\u27F3 Sync";sy.title="Pull teammate updates from your GHL";
|
|
45753
|
+
sy.onclick=async()=>{sy.textContent="Syncing\u2026";const r=await(await fetch("/api/sync",{method:"POST",headers:{"Content-Type":"application/json"}})).json();
|
|
45754
|
+
sy.textContent=r.adopted?("\u27F3 "+r.adopted+" updated"):"\u27F3 Up to date";setTimeout(()=>{sy.textContent="\u27F3 Sync"},2500);load()};
|
|
45755
|
+
bar.appendChild(sy)}
|
|
45756
|
+
m.appendChild(bar);
|
|
43704
45757
|
const q=query.trim().toLowerCase();
|
|
43705
45758
|
const match=(c)=>!q||c.name.toLowerCase().includes(q)||c.locationId.toLowerCase().includes(q);
|
|
43706
|
-
const qb=queueBlock(match);if(qb)m.appendChild(qb);
|
|
45759
|
+
if(SCREEN==="queue"){const qb=queueBlock(match);if(qb)m.appendChild(qb);else{const d=document.createElement("div");d.className="client";d.innerHTML="<h2>Nothing is waiting on you</h2>";m.appendChild(d)}return}
|
|
43707
45760
|
const sorted=[...ROSTER].sort((a,b)=>activityScore(state,b.locationId)-activityScore(state,a.locationId)||a.name.localeCompare(b.name));
|
|
43708
45761
|
const board=sorted.filter(c=>match(c)&&["client","sandbox"].includes(typeOf(state,c.locationId)));
|
|
43709
45762
|
const parked=sorted.filter(c=>match(c)&&["template","hidden"].includes(typeOf(state,c.locationId)));
|
|
@@ -43720,10 +45773,10 @@ function render(state,run){syncReviews(state);lastState=state;lastRun=run;
|
|
|
43720
45773
|
const tx=document.createElement("span");tx.style.fontSize="var(--fs-sm)";tx.textContent=other.name+" \u2014 "+(LABELS[run.stage]||"")+" is running.";ban.appendChild(tx);
|
|
43721
45774
|
const go2=document.createElement("button");go2.className="intakebtn";go2.style.marginLeft="auto";go2.textContent="Watch it";
|
|
43722
45775
|
go2.onclick=()=>setActive(other.locationId);ban.appendChild(go2);m.appendChild(ban)}}
|
|
43723
|
-
if(actC){const eb=document.createElement("div");eb.className="eyebrow";eb.textContent="Working on now";m.appendChild(eb);
|
|
45776
|
+
if(actC){const eb=document.createElement("div");eb.className="eyebrow";eb.id="clients";eb.textContent="Working on now";m.appendChild(eb);
|
|
43724
45777
|
m.appendChild(actC.pending?pendingCard(actC,state):card(actC,state,run))}
|
|
43725
45778
|
const rest=board.filter(c=>c.locationId!==act);
|
|
43726
|
-
if(rest.length){const eb2=document.createElement("div");eb2.className="eyebrow";eb2.textContent="Your other accounts";m.appendChild(eb2);
|
|
45779
|
+
if(rest.length){const eb2=document.createElement("div");eb2.className="eyebrow";if(!actC)eb2.id="clients";eb2.textContent=actC?"Your other client accounts":"Your client accounts";m.appendChild(eb2);
|
|
43727
45780
|
const list=document.createElement("div");list.className="card acct-list";
|
|
43728
45781
|
for(const c of rest)list.appendChild(acctRow(c,state,run));
|
|
43729
45782
|
m.appendChild(list);
|
|
@@ -43783,6 +45836,24 @@ function checkMutationGuard(req, port) {
|
|
|
43783
45836
|
}
|
|
43784
45837
|
return null;
|
|
43785
45838
|
}
|
|
45839
|
+
function cockpitGate(req, seatToken, port) {
|
|
45840
|
+
const denied = checkMutationGuard(req, port) ?? checkSeatToken(req, seatToken);
|
|
45841
|
+
if (denied) return denied;
|
|
45842
|
+
const pathname = (req.url ?? "/").split("?")[0];
|
|
45843
|
+
if (SEAT_GUARDED_READS.includes(pathname)) return checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
|
|
45844
|
+
return null;
|
|
45845
|
+
}
|
|
45846
|
+
function guarded(seatToken, port, routes) {
|
|
45847
|
+
return (req, res) => {
|
|
45848
|
+
const denied = cockpitGate(req, seatToken, port);
|
|
45849
|
+
if (denied) {
|
|
45850
|
+
res.writeHead(denied.status, { "Content-Type": "application/json" });
|
|
45851
|
+
res.end(JSON.stringify({ ok: false, error: denied.error }));
|
|
45852
|
+
return;
|
|
45853
|
+
}
|
|
45854
|
+
routes(req, res);
|
|
45855
|
+
};
|
|
45856
|
+
}
|
|
43786
45857
|
async function runDashboard(argv) {
|
|
43787
45858
|
const portArg = argv.find((a) => a.startsWith("--port="));
|
|
43788
45859
|
const port = portArg ? Number(portArg.split("=")[1]) : 7300;
|
|
@@ -43834,6 +45905,7 @@ async function runDashboard(argv) {
|
|
|
43834
45905
|
}
|
|
43835
45906
|
return process.env.GHL_COMPANY_ID;
|
|
43836
45907
|
};
|
|
45908
|
+
currentRosterRef = () => currentRoster();
|
|
43837
45909
|
const clientFor = (locationId2) => {
|
|
43838
45910
|
const tok = registry2.getToken(locationId2);
|
|
43839
45911
|
return tok?.apiKey ? new GHLClient({ apiKey: tok.apiKey, locationId: locationId2 }) : void 0;
|
|
@@ -43892,26 +45964,178 @@ async function runDashboard(argv) {
|
|
|
43892
45964
|
const pend = Object.entries(readCockpitState().pending ?? {}).filter(([id]) => !ids.has(id)).map(([locationId2, p]) => ({ locationId: locationId2, name: p.name, pending: true }));
|
|
43893
45965
|
return [...registered, ...pend];
|
|
43894
45966
|
};
|
|
43895
|
-
const server2 = http.createServer((req, res) => {
|
|
45967
|
+
const server2 = http.createServer(guarded(seatToken, port, (req, res) => {
|
|
43896
45968
|
const url = req.url || "/";
|
|
43897
|
-
|
|
43898
|
-
|
|
43899
|
-
|
|
43900
|
-
|
|
45969
|
+
if (req.method === "GET" && url.startsWith("/for/")) {
|
|
45970
|
+
const cap = capabilityByKey(url.slice("/for/".length).replace(/\/$/, ""));
|
|
45971
|
+
if (!cap) {
|
|
45972
|
+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
45973
|
+
res.end("No such page. The sidebar lists every one.");
|
|
45974
|
+
return;
|
|
45975
|
+
}
|
|
45976
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
45977
|
+
res.end(html(currentRoster(), isLoopback(req) ? seatToken : "", "for", cap));
|
|
43901
45978
|
return;
|
|
43902
45979
|
}
|
|
43903
|
-
if (req.method === "GET" && url
|
|
43904
|
-
|
|
43905
|
-
|
|
45980
|
+
if (req.method === "GET" && url.startsWith("/api/capability")) {
|
|
45981
|
+
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
45982
|
+
const cap = capabilityByKey(u.searchParams.get("key") ?? "");
|
|
45983
|
+
const locationId2 = u.searchParams.get("locationId") ?? "";
|
|
45984
|
+
const seat = checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
|
|
45985
|
+
if (seat) {
|
|
45986
|
+
res.writeHead(seat.status, { "Content-Type": "application/json" });
|
|
45987
|
+
res.end(JSON.stringify({ ok: false, error: seat.error }));
|
|
45988
|
+
return;
|
|
45989
|
+
}
|
|
45990
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
45991
|
+
if (!cap) {
|
|
45992
|
+
res.end(JSON.stringify({ ok: false, error: "no such capability" }));
|
|
45993
|
+
return;
|
|
45994
|
+
}
|
|
45995
|
+
if (!readableAccount(locationId2)) {
|
|
45996
|
+
res.end(JSON.stringify({ ok: false, error: "That account is not on the board." }));
|
|
45997
|
+
return;
|
|
45998
|
+
}
|
|
45999
|
+
const c = clientFor(locationId2);
|
|
46000
|
+
if (!c) {
|
|
46001
|
+
res.end(JSON.stringify({ ok: false, error: "No key on file for this account. Register it in GHL Command first." }));
|
|
46002
|
+
return;
|
|
46003
|
+
}
|
|
46004
|
+
void (async () => {
|
|
46005
|
+
const reads = await Promise.all(cap.reads.map(async (r) => {
|
|
46006
|
+
const p = r.path.replace(/\{loc\}/g, locationId2);
|
|
46007
|
+
const params = r.params ? Object.fromEntries(Object.entries(r.params).map(([k, v]) => [k, v.replace(/\{loc\}/g, locationId2)])) : void 0;
|
|
46008
|
+
try {
|
|
46009
|
+
const raw = await c.get(p, { ...params ? { params } : {}, ...r.version ? { version: r.version } : {} });
|
|
46010
|
+
const items = findArray(raw, r.key);
|
|
46011
|
+
if (!items) {
|
|
46012
|
+
logQuietly(`capability read ${cap.key}/${r.label}: unrecognised shape from ${p}`);
|
|
46013
|
+
return { label: r.label, status: "unavailable", count: 0, names: [], reason: "could not read: the answer had a shape this page does not know" };
|
|
46014
|
+
}
|
|
46015
|
+
return { label: r.label, status: "ok", count: items.length, names: items.map((i) => nameOf(i, r.nameField)).filter(Boolean) };
|
|
46016
|
+
} catch (e) {
|
|
46017
|
+
logQuietly(`capability read ${cap.key}/${r.label} on ${locationId2}: ${e instanceof Error ? e.message : String(e)}`);
|
|
46018
|
+
return { label: r.label, status: "unavailable", count: 0, names: [], reason: readFailureText(e) };
|
|
46019
|
+
}
|
|
46020
|
+
}));
|
|
46021
|
+
res.end(JSON.stringify({ ok: true, key: cap.key, locationId: locationId2, reads }));
|
|
46022
|
+
})();
|
|
43906
46023
|
return;
|
|
43907
46024
|
}
|
|
43908
|
-
if (
|
|
43909
|
-
|
|
43910
|
-
|
|
43911
|
-
|
|
43912
|
-
|
|
46025
|
+
if (req.method === "POST" && url === "/api/run-capability") {
|
|
46026
|
+
readBody(req, res, 1e4, (body) => {
|
|
46027
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
46028
|
+
try {
|
|
46029
|
+
const { locationId: locationId2, key } = JSON.parse(body || "{}");
|
|
46030
|
+
const cap = capabilityByKey(String(key ?? ""));
|
|
46031
|
+
if (!cap) throw new Error("no such capability");
|
|
46032
|
+
if (activeRun2?.running) throw new Error("a run is already in progress");
|
|
46033
|
+
const st = readCockpitState();
|
|
46034
|
+
const client = currentRoster().find((r) => r.locationId === locationId2);
|
|
46035
|
+
if (!client) throw new Error("that account is not on the board");
|
|
46036
|
+
const approvedAt = st.buildApproved?.[locationId2];
|
|
46037
|
+
startRun(locationId2, -1, (ok, outcome) => {
|
|
46038
|
+
try {
|
|
46039
|
+
const now = readCockpitState();
|
|
46040
|
+
const last = activeRun2;
|
|
46041
|
+
const runs = { ...now.capabilityRuns ?? {} };
|
|
46042
|
+
const done = (last?.lines ?? []).filter((l) => /^Done[:.]/.test(l)).pop() ?? "";
|
|
46043
|
+
runs[`${locationId2}:${cap.key}`] = {
|
|
46044
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
46045
|
+
ok,
|
|
46046
|
+
summary: outcome?.plain?.summary ?? outcome?.summary ?? done.replace(/^Done[:.]\s*/, ""),
|
|
46047
|
+
issues: outcome?.plain?.issues ?? outcome?.issues ?? [],
|
|
46048
|
+
error: ok ? void 0 : outcome?.error ?? "the run did not finish"
|
|
46049
|
+
};
|
|
46050
|
+
writeCockpitState({ ...now, capabilityRuns: runs });
|
|
46051
|
+
} catch {
|
|
46052
|
+
}
|
|
46053
|
+
}, { name: client.name, approvedAt, spec: capabilitySpec(cap, client.name), capability: cap.key });
|
|
46054
|
+
res.end(JSON.stringify({ ok: true }));
|
|
46055
|
+
} catch (e) {
|
|
46056
|
+
res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
|
|
46057
|
+
}
|
|
46058
|
+
});
|
|
46059
|
+
return;
|
|
46060
|
+
}
|
|
46061
|
+
if (req.method === "POST" && url === "/api/assessment-save") {
|
|
46062
|
+
readBody(req, res, 2e6, (body) => {
|
|
46063
|
+
void (async () => {
|
|
46064
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
46065
|
+
try {
|
|
46066
|
+
const args = JSON.parse(body || "{}");
|
|
46067
|
+
const locationId2 = String(args.locationId ?? "");
|
|
46068
|
+
if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("Pick which account this assessment belongs to first.");
|
|
46069
|
+
const c = clientFor(locationId2);
|
|
46070
|
+
if (!c) throw new Error("No key on file for that account. Register it in GHL Command first.");
|
|
46071
|
+
const assessment = StateSchema.parse(args.assessment);
|
|
46072
|
+
const out = await saveAssessment(c, locationId2, {
|
|
46073
|
+
assessment,
|
|
46074
|
+
contactName: args.contactName || void 0,
|
|
46075
|
+
email: args.email || void 0,
|
|
46076
|
+
phone: args.phone || void 0,
|
|
46077
|
+
createPipeline: args.createPipeline === true
|
|
46078
|
+
});
|
|
46079
|
+
res.end(JSON.stringify({ ok: true, ...out }));
|
|
46080
|
+
} catch (e) {
|
|
46081
|
+
res.end(JSON.stringify({ ok: false, error: e instanceof Error ? e.message : String(e) }));
|
|
46082
|
+
}
|
|
46083
|
+
})();
|
|
46084
|
+
});
|
|
46085
|
+
return;
|
|
46086
|
+
}
|
|
46087
|
+
if (req.method === "GET" && url.startsWith("/api/intake-documents")) {
|
|
46088
|
+
const u = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
46089
|
+
const seat = checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
|
|
46090
|
+
if (seat) {
|
|
46091
|
+
res.writeHead(seat.status, { "Content-Type": "application/json" });
|
|
46092
|
+
res.end(JSON.stringify({ ok: false, error: seat.error }));
|
|
46093
|
+
return;
|
|
46094
|
+
}
|
|
46095
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
46096
|
+
const loc = u.searchParams.get("locationId") ?? "";
|
|
46097
|
+
if (!readableAccount(loc)) {
|
|
46098
|
+
res.end(JSON.stringify({ ok: false, error: "That account is not on the board." }));
|
|
43913
46099
|
return;
|
|
43914
46100
|
}
|
|
46101
|
+
try {
|
|
46102
|
+
res.end(JSON.stringify({ ok: true, files: listClientDocs(loc) }));
|
|
46103
|
+
} catch (e) {
|
|
46104
|
+
logQuietly(`list documents ${loc}: ${e instanceof Error ? e.message : String(e)}`);
|
|
46105
|
+
res.end(JSON.stringify({ ok: false, error: "Could not list this client's documents." }));
|
|
46106
|
+
}
|
|
46107
|
+
return;
|
|
46108
|
+
}
|
|
46109
|
+
if (req.method === "POST" && url === "/api/intake-document") {
|
|
46110
|
+
readBody(req, res, Math.ceil(DOC_MAX_BYTES * 1.4), (body) => {
|
|
46111
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
46112
|
+
try {
|
|
46113
|
+
const args = JSON.parse(body || "{}");
|
|
46114
|
+
if (!readableAccount(String(args.locationId ?? ""))) throw new Error("That account is not on the board.");
|
|
46115
|
+
const name = safeDocName(args.name);
|
|
46116
|
+
const bytes = Buffer.from(String(args.base64 ?? ""), "base64");
|
|
46117
|
+
if (!bytes.length) throw new Error("that file is empty");
|
|
46118
|
+
if (bytes.length > DOC_MAX_BYTES) throw new Error("that file is over 20 MB");
|
|
46119
|
+
const dir = clientDocsDir(args.locationId);
|
|
46120
|
+
fs32.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
46121
|
+
if (!insideDir(path31.join(appDataDir(), "clients"), dir)) throw new Error("This client's documents folder is not where it should be, so nothing was saved.");
|
|
46122
|
+
const saved = writeDocAtomically(dir, name, bytes);
|
|
46123
|
+
res.end(JSON.stringify({ ok: true, name: saved, files: listClientDocs(args.locationId) }));
|
|
46124
|
+
} catch (e) {
|
|
46125
|
+
logQuietly(`save document: ${e instanceof Error ? e.message : String(e)}`);
|
|
46126
|
+
const msg3 = e instanceof Error ? e.message : String(e);
|
|
46127
|
+
res.end(JSON.stringify({ ok: false, error: /\/|\\/.test(msg3) ? "That file could not be saved." : msg3 }));
|
|
46128
|
+
}
|
|
46129
|
+
});
|
|
46130
|
+
return;
|
|
46131
|
+
}
|
|
46132
|
+
const boardScreen = BOARD_ROUTES[url];
|
|
46133
|
+
if (req.method === "GET" && boardScreen) {
|
|
46134
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
46135
|
+
res.end(html(currentRoster(), isLoopback(req) ? seatToken : "", boardScreen));
|
|
46136
|
+
return;
|
|
46137
|
+
}
|
|
46138
|
+
if ((req.method === "GET" || req.method === "HEAD") && (url === "/assessment" || url === "/assessment.html")) {
|
|
43915
46139
|
res.writeHead(200, {
|
|
43916
46140
|
"Content-Type": "text/html; charset=utf-8",
|
|
43917
46141
|
// It holds another business's revenue and close rate the moment it is
|
|
@@ -43919,23 +46143,33 @@ async function runDashboard(argv) {
|
|
|
43919
46143
|
"Cache-Control": "no-store",
|
|
43920
46144
|
"X-Robots-Tag": "noindex, nofollow"
|
|
43921
46145
|
});
|
|
43922
|
-
|
|
46146
|
+
const roster2 = currentRoster().map((r) => ({ locationId: r.locationId, name: r.name }));
|
|
46147
|
+
const clientsTag = `<script>window.COMMAND_OS_CLIENTS=${scriptJson(roster2)};window.COMMAND_OS_SEAT=${scriptJson(isLoopback(req) ? seatToken : "")};</script>`;
|
|
46148
|
+
res.end(req.method === "HEAD" ? void 0 : withCockpitShell(ASSESSMENT_HTML, "assessment", assessmentAgencyTag() + clientsTag));
|
|
43923
46149
|
return;
|
|
43924
46150
|
}
|
|
43925
46151
|
if (req.method === "GET" && url === "/api/snapshots") {
|
|
46152
|
+
const seat = checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
|
|
46153
|
+
if (seat) {
|
|
46154
|
+
res.writeHead(seat.status, { "Content-Type": "application/json" });
|
|
46155
|
+
res.end(JSON.stringify({ ok: false, error: seat.error }));
|
|
46156
|
+
return;
|
|
46157
|
+
}
|
|
43926
46158
|
(async () => {
|
|
43927
46159
|
try {
|
|
43928
46160
|
const agencyKey = registry2.getAgencyKey();
|
|
43929
46161
|
const companyId = agencyCompanyId();
|
|
43930
|
-
if (!agencyKey || !companyId) throw new Error(
|
|
46162
|
+
if (!agencyKey || !companyId) throw new Error(NEEDS_AGENCY_KEY);
|
|
43931
46163
|
const agencyClient = new GHLClient({ apiKey: agencyKey });
|
|
43932
46164
|
const raw = await agencyClient.get("/snapshots/", { params: { companyId } });
|
|
43933
46165
|
const snaps = (raw.snapshots ?? []).sort((a, b) => (a.type === "own" ? 0 : 1) - (b.type === "own" ? 0 : 1) || a.name.localeCompare(b.name));
|
|
43934
46166
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
43935
46167
|
res.end(JSON.stringify({ ok: true, snapshots: snaps }));
|
|
43936
46168
|
} catch (e) {
|
|
43937
|
-
|
|
43938
|
-
|
|
46169
|
+
const shown = memberFacingError(e, [NEEDS_AGENCY_KEY]);
|
|
46170
|
+
logQuietly(`snapshot list: ${e instanceof Error ? e.message : String(e)}`);
|
|
46171
|
+
res.writeHead(shown === NEEDS_AGENCY_KEY ? 400 : 500, { "Content-Type": "application/json" });
|
|
46172
|
+
res.end(JSON.stringify({ ok: false, error: shown === NEEDS_AGENCY_KEY ? shown : `Could not list your snapshots: ${shown}.` }));
|
|
43939
46173
|
}
|
|
43940
46174
|
})();
|
|
43941
46175
|
return;
|
|
@@ -43963,12 +46197,7 @@ async function runDashboard(argv) {
|
|
|
43963
46197
|
return;
|
|
43964
46198
|
}
|
|
43965
46199
|
if (req.method === "POST" && url === "/api/run") {
|
|
43966
|
-
|
|
43967
|
-
req.on("data", (c) => {
|
|
43968
|
-
body += c;
|
|
43969
|
-
if (body.length > 1e4) req.destroy();
|
|
43970
|
-
});
|
|
43971
|
-
req.on("end", () => {
|
|
46200
|
+
readBody(req, res, 1e4, (body) => {
|
|
43972
46201
|
try {
|
|
43973
46202
|
const { locationId: locationId2, stage } = JSON.parse(body);
|
|
43974
46203
|
if (activeRun2?.running) throw new Error("a run is already in progress");
|
|
@@ -44014,12 +46243,7 @@ async function runDashboard(argv) {
|
|
|
44014
46243
|
return;
|
|
44015
46244
|
}
|
|
44016
46245
|
if (req.method === "POST" && url === "/api/overlay") {
|
|
44017
|
-
|
|
44018
|
-
req.on("data", (c) => {
|
|
44019
|
-
body += c;
|
|
44020
|
-
if (body.length > 1e5) req.destroy();
|
|
44021
|
-
});
|
|
44022
|
-
req.on("end", () => {
|
|
46246
|
+
readBody(req, res, 1e5, (body) => {
|
|
44023
46247
|
try {
|
|
44024
46248
|
const layer = JSON.parse(body);
|
|
44025
46249
|
if (typeof layer !== "object" || layer === null) throw new Error("bad overlay");
|
|
@@ -44037,6 +46261,13 @@ async function runDashboard(argv) {
|
|
|
44037
46261
|
(async () => {
|
|
44038
46262
|
try {
|
|
44039
46263
|
const locationId2 = new URL(url, "http://x").searchParams.get("locationId") || "";
|
|
46264
|
+
const seat = checkSeatToken({ method: "POST", headers: req.headers }, seatToken);
|
|
46265
|
+
if (seat) {
|
|
46266
|
+
res.writeHead(seat.status, { "Content-Type": "application/json" });
|
|
46267
|
+
res.end(JSON.stringify({ ok: false, error: seat.error }));
|
|
46268
|
+
return;
|
|
46269
|
+
}
|
|
46270
|
+
if (!readableAccount(locationId2)) throw new Error("That account is not on the board.");
|
|
44040
46271
|
const c = clientFor(locationId2);
|
|
44041
46272
|
if (!c) throw new Error("this client isn't connected yet \u2014 paste its key first");
|
|
44042
46273
|
const st = readCockpitState();
|
|
@@ -44047,18 +46278,14 @@ async function runDashboard(argv) {
|
|
|
44047
46278
|
res.end(JSON.stringify({ ok: true, ...snap, questions }));
|
|
44048
46279
|
} catch (e) {
|
|
44049
46280
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
44050
|
-
|
|
46281
|
+
logQuietly(`client-intake ${new URL(url, "http://x").searchParams.get("locationId") || ""}: ${e instanceof Error ? e.message : String(e)}`);
|
|
46282
|
+
res.end(JSON.stringify({ ok: false, error: memberFacingError(e, INTAKE_OWN_MESSAGES) }));
|
|
44051
46283
|
}
|
|
44052
46284
|
})();
|
|
44053
46285
|
return;
|
|
44054
46286
|
}
|
|
44055
46287
|
if (req.method === "POST" && url === "/api/intake-prefill-url") {
|
|
44056
|
-
|
|
44057
|
-
req.on("data", (c) => {
|
|
44058
|
-
body += c;
|
|
44059
|
-
if (body.length > 2e5) req.destroy();
|
|
44060
|
-
});
|
|
44061
|
-
req.on("end", async () => {
|
|
46288
|
+
readBody(req, res, 2e5, async (body) => {
|
|
44062
46289
|
try {
|
|
44063
46290
|
const args = JSON.parse(body);
|
|
44064
46291
|
const c = clientFor(args.locationId);
|
|
@@ -44076,18 +46303,22 @@ async function runDashboard(argv) {
|
|
|
44076
46303
|
return;
|
|
44077
46304
|
}
|
|
44078
46305
|
if (req.method === "POST" && (url === "/api/intake-submit" || url === "/api/intake-send" || url === "/api/intake-prefill")) {
|
|
44079
|
-
|
|
44080
|
-
req.on("data", (c) => {
|
|
44081
|
-
body += c;
|
|
44082
|
-
if (body.length > 4e5) req.destroy();
|
|
44083
|
-
});
|
|
44084
|
-
req.on("end", async () => {
|
|
46306
|
+
readBody(req, res, 4e5, async (body) => {
|
|
44085
46307
|
try {
|
|
44086
46308
|
const args = JSON.parse(body);
|
|
44087
46309
|
const c = clientFor(args.locationId);
|
|
44088
46310
|
if (!c) throw new Error("this client isn't connected yet");
|
|
44089
46311
|
if (url === "/api/intake-prefill") {
|
|
44090
|
-
|
|
46312
|
+
let fileSource = null;
|
|
46313
|
+
if (args.file) {
|
|
46314
|
+
const name = safeDocName(args.file);
|
|
46315
|
+
const dir = clientDocsDir(args.locationId);
|
|
46316
|
+
const p = path31.join(dir, name);
|
|
46317
|
+
if (!fs32.existsSync(p)) throw new Error("that document is not saved for this client");
|
|
46318
|
+
if (fs32.lstatSync(p).isSymbolicLink() || !insideDir(dir, p)) throw new Error("that document is not saved for this client");
|
|
46319
|
+
fileSource = { kind: "file", path: p, name };
|
|
46320
|
+
}
|
|
46321
|
+
const source = fileSource ? fileSource : args.url ? { kind: "url", url: String(args.url) } : args.instruction ? { kind: "recorder", instruction: String(args.instruction) } : { kind: "text", text: String(args.transcript ?? "") };
|
|
44091
46322
|
let recorderPatterns = [];
|
|
44092
46323
|
if (source.kind === "recorder") {
|
|
44093
46324
|
recorderPatterns = await new Promise((resolve11) => {
|
|
@@ -44195,12 +46426,7 @@ async function runDashboard(argv) {
|
|
|
44195
46426
|
return;
|
|
44196
46427
|
}
|
|
44197
46428
|
if (req.method === "POST" && url === "/api/design/run") {
|
|
44198
|
-
|
|
44199
|
-
req.on("data", (c) => {
|
|
44200
|
-
body += c;
|
|
44201
|
-
if (body.length > 6e4) req.destroy();
|
|
44202
|
-
});
|
|
44203
|
-
req.on("end", () => {
|
|
46429
|
+
readBody(req, res, 6e4, (body) => {
|
|
44204
46430
|
try {
|
|
44205
46431
|
const checked = validateRunRequest(JSON.parse(body || "{}"));
|
|
44206
46432
|
if (!checked.ok) {
|
|
@@ -44226,12 +46452,7 @@ async function runDashboard(argv) {
|
|
|
44226
46452
|
return;
|
|
44227
46453
|
}
|
|
44228
46454
|
if (req.method === "POST" && url === "/api/design/cancel") {
|
|
44229
|
-
|
|
44230
|
-
req.on("data", (c) => {
|
|
44231
|
-
body += c;
|
|
44232
|
-
if (body.length > 4e3) req.destroy();
|
|
44233
|
-
});
|
|
44234
|
-
req.on("end", () => {
|
|
46455
|
+
readBody(req, res, 4e3, (body) => {
|
|
44235
46456
|
const id = (() => {
|
|
44236
46457
|
try {
|
|
44237
46458
|
return String(JSON.parse(body || "{}").id ?? "");
|
|
@@ -44246,12 +46467,7 @@ async function runDashboard(argv) {
|
|
|
44246
46467
|
return;
|
|
44247
46468
|
}
|
|
44248
46469
|
if (req.method === "POST" && (url === "/api/brand/extract" || url === "/api/brand/import-look" || url === "/api/brand/set")) {
|
|
44249
|
-
|
|
44250
|
-
req.on("data", (c) => {
|
|
44251
|
-
body += c;
|
|
44252
|
-
if (body.length > 12e4) req.destroy();
|
|
44253
|
-
});
|
|
44254
|
-
req.on("end", () => {
|
|
46470
|
+
readBody(req, res, 12e4, (body) => {
|
|
44255
46471
|
void (async () => {
|
|
44256
46472
|
try {
|
|
44257
46473
|
const a = JSON.parse(body || "{}");
|
|
@@ -44310,8 +46526,11 @@ async function runDashboard(argv) {
|
|
|
44310
46526
|
}
|
|
44311
46527
|
let logoSvg;
|
|
44312
46528
|
if (typeof a.logoSvg === "string" && a.logoSvg.trim()) {
|
|
44313
|
-
|
|
44314
|
-
|
|
46529
|
+
try {
|
|
46530
|
+
logoSvg = sanitizeSvg(a.logoSvg);
|
|
46531
|
+
} catch (e) {
|
|
46532
|
+
throw new Error(`${e.message} Nothing was changed.`);
|
|
46533
|
+
}
|
|
44315
46534
|
d.logo = { inlineSvg: logoSvg, whiteFillFixed: false, source: `set by ${stamp}`, rights: "declared" };
|
|
44316
46535
|
d.provenance.evidence["logo"] = `set by ${stamp}`;
|
|
44317
46536
|
}
|
|
@@ -44336,12 +46555,7 @@ async function runDashboard(argv) {
|
|
|
44336
46555
|
return;
|
|
44337
46556
|
}
|
|
44338
46557
|
if (req.method === "POST" && url === "/api/client-engagement") {
|
|
44339
|
-
|
|
44340
|
-
req.on("data", (c) => {
|
|
44341
|
-
body += c;
|
|
44342
|
-
if (body.length > 2e4) req.destroy();
|
|
44343
|
-
});
|
|
44344
|
-
req.on("end", () => {
|
|
46558
|
+
readBody(req, res, 2e4, (body) => {
|
|
44345
46559
|
try {
|
|
44346
46560
|
const { locationId: locationId2, ...patch } = JSON.parse(body);
|
|
44347
46561
|
const savedAll = writeClientEngagement(String(locationId2 ?? ""), patch);
|
|
@@ -44379,12 +46593,7 @@ async function runDashboard(argv) {
|
|
|
44379
46593
|
return;
|
|
44380
46594
|
}
|
|
44381
46595
|
if (req.method === "POST" && url === "/api/build-approval") {
|
|
44382
|
-
|
|
44383
|
-
req.on("data", (c) => {
|
|
44384
|
-
body += c;
|
|
44385
|
-
if (body.length > 5e3) req.destroy();
|
|
44386
|
-
});
|
|
44387
|
-
req.on("end", () => {
|
|
46596
|
+
readBody(req, res, 5e3, (body) => {
|
|
44388
46597
|
try {
|
|
44389
46598
|
const { locationId: locationId2, confirmName, revoke } = JSON.parse(body);
|
|
44390
46599
|
const entry = currentRoster().find((r) => r.locationId === locationId2);
|
|
@@ -44404,12 +46613,7 @@ async function runDashboard(argv) {
|
|
|
44404
46613
|
return;
|
|
44405
46614
|
}
|
|
44406
46615
|
if (req.method === "POST" && url === "/api/protect") {
|
|
44407
|
-
|
|
44408
|
-
req.on("data", (c) => {
|
|
44409
|
-
body += c;
|
|
44410
|
-
if (body.length > 5e3) req.destroy();
|
|
44411
|
-
});
|
|
44412
|
-
req.on("end", () => {
|
|
46616
|
+
readBody(req, res, 5e3, (body) => {
|
|
44413
46617
|
try {
|
|
44414
46618
|
const { locationId: locationId2, protect, confirmName } = JSON.parse(body);
|
|
44415
46619
|
const entry = currentRoster().find((r) => r.locationId === locationId2);
|
|
@@ -44433,12 +46637,7 @@ async function runDashboard(argv) {
|
|
|
44433
46637
|
return;
|
|
44434
46638
|
}
|
|
44435
46639
|
if (req.method === "POST" && url === "/api/manual-task") {
|
|
44436
|
-
|
|
44437
|
-
req.on("data", (c) => {
|
|
44438
|
-
body += c;
|
|
44439
|
-
if (body.length > 5e3) req.destroy();
|
|
44440
|
-
});
|
|
44441
|
-
req.on("end", () => {
|
|
46640
|
+
readBody(req, res, 5e3, (body) => {
|
|
44442
46641
|
try {
|
|
44443
46642
|
const { locationId: locationId2, id, claimed } = JSON.parse(body);
|
|
44444
46643
|
if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
|
|
@@ -44469,12 +46668,7 @@ async function runDashboard(argv) {
|
|
|
44469
46668
|
return;
|
|
44470
46669
|
}
|
|
44471
46670
|
if (req.method === "POST" && url === "/api/anthropic-key") {
|
|
44472
|
-
|
|
44473
|
-
req.on("data", (c) => {
|
|
44474
|
-
body += c;
|
|
44475
|
-
if (body.length > 8e3) req.destroy();
|
|
44476
|
-
});
|
|
44477
|
-
req.on("end", () => {
|
|
46671
|
+
readBody(req, res, 8e3, (body) => {
|
|
44478
46672
|
anthropicKeyWrites = anthropicKeyWrites.then(async () => {
|
|
44479
46673
|
try {
|
|
44480
46674
|
let parsed;
|
|
@@ -44538,12 +46732,7 @@ async function runDashboard(argv) {
|
|
|
44538
46732
|
return;
|
|
44539
46733
|
}
|
|
44540
46734
|
if (req.method === "POST" && url === "/api/agency-profile") {
|
|
44541
|
-
|
|
44542
|
-
req.on("data", (c) => {
|
|
44543
|
-
body += c;
|
|
44544
|
-
if (body.length > 4e4) req.destroy();
|
|
44545
|
-
});
|
|
44546
|
-
req.on("end", () => {
|
|
46735
|
+
readBody(req, res, 4e4, (body) => {
|
|
44547
46736
|
try {
|
|
44548
46737
|
const saved = writeAgencyProfile(JSON.parse(body));
|
|
44549
46738
|
const st = readCockpitState();
|
|
@@ -44558,12 +46747,7 @@ async function runDashboard(argv) {
|
|
|
44558
46747
|
return;
|
|
44559
46748
|
}
|
|
44560
46749
|
if (req.method === "POST" && url === "/api/branding") {
|
|
44561
|
-
|
|
44562
|
-
req.on("data", (c) => {
|
|
44563
|
-
body += c;
|
|
44564
|
-
if (body.length > 5e3) req.destroy();
|
|
44565
|
-
});
|
|
44566
|
-
req.on("end", () => {
|
|
46750
|
+
readBody(req, res, 5e3, (body) => {
|
|
44567
46751
|
try {
|
|
44568
46752
|
const { agencyName, agencyAccent } = JSON.parse(body);
|
|
44569
46753
|
const st = readCockpitState();
|
|
@@ -44579,12 +46763,7 @@ async function runDashboard(argv) {
|
|
|
44579
46763
|
return;
|
|
44580
46764
|
}
|
|
44581
46765
|
if (req.method === "POST" && url === "/api/review") {
|
|
44582
|
-
|
|
44583
|
-
req.on("data", (c) => {
|
|
44584
|
-
body += c;
|
|
44585
|
-
if (body.length > 2e4) req.destroy();
|
|
44586
|
-
});
|
|
44587
|
-
req.on("end", async () => {
|
|
46766
|
+
readBody(req, res, 2e4, async (body) => {
|
|
44588
46767
|
try {
|
|
44589
46768
|
const { locationId: locationId2, clientName } = JSON.parse(body);
|
|
44590
46769
|
const review = await writeReviewFor(locationId2, clientName || locationId2);
|
|
@@ -44600,12 +46779,7 @@ async function runDashboard(argv) {
|
|
|
44600
46779
|
return;
|
|
44601
46780
|
}
|
|
44602
46781
|
if (req.method === "POST" && url === "/api/new-client") {
|
|
44603
|
-
|
|
44604
|
-
req.on("data", (c) => {
|
|
44605
|
-
body += c;
|
|
44606
|
-
if (body.length > 2e4) req.destroy();
|
|
44607
|
-
});
|
|
44608
|
-
req.on("end", async () => {
|
|
46782
|
+
readBody(req, res, 2e4, async (body) => {
|
|
44609
46783
|
try {
|
|
44610
46784
|
const args = JSON.parse(body);
|
|
44611
46785
|
if (!args.name || typeof args.name !== "string") throw new Error("business name is required");
|
|
@@ -44637,12 +46811,7 @@ async function runDashboard(argv) {
|
|
|
44637
46811
|
return;
|
|
44638
46812
|
}
|
|
44639
46813
|
if (req.method === "POST" && url === "/api/register-location") {
|
|
44640
|
-
|
|
44641
|
-
req.on("data", (c) => {
|
|
44642
|
-
body += c;
|
|
44643
|
-
if (body.length > 1e4) req.destroy();
|
|
44644
|
-
});
|
|
44645
|
-
req.on("end", async () => {
|
|
46814
|
+
readBody(req, res, 1e4, async (body) => {
|
|
44646
46815
|
try {
|
|
44647
46816
|
const { locationId: locationId2, apiKey: apiKey2 } = JSON.parse(body);
|
|
44648
46817
|
const pendingEntry = readCockpitState().pending?.[locationId2];
|
|
@@ -44662,12 +46831,7 @@ async function runDashboard(argv) {
|
|
|
44662
46831
|
return;
|
|
44663
46832
|
}
|
|
44664
46833
|
if (req.method === "POST" && url === "/api/pending-remove") {
|
|
44665
|
-
|
|
44666
|
-
req.on("data", (c) => {
|
|
44667
|
-
body += c;
|
|
44668
|
-
if (body.length > 1e4) req.destroy();
|
|
44669
|
-
});
|
|
44670
|
-
req.on("end", () => {
|
|
46834
|
+
readBody(req, res, 1e4, (body) => {
|
|
44671
46835
|
try {
|
|
44672
46836
|
const { locationId: locationId2 } = JSON.parse(body);
|
|
44673
46837
|
writeCockpitState(clearPending(readCockpitState(), locationId2));
|
|
@@ -44689,12 +46853,7 @@ async function runDashboard(argv) {
|
|
|
44689
46853
|
return;
|
|
44690
46854
|
}
|
|
44691
46855
|
if (req.method === "POST" && url === "/api/industry") {
|
|
44692
|
-
|
|
44693
|
-
req.on("data", (c) => {
|
|
44694
|
-
body += c;
|
|
44695
|
-
if (body.length > 1e4) req.destroy();
|
|
44696
|
-
});
|
|
44697
|
-
req.on("end", () => {
|
|
46856
|
+
readBody(req, res, 1e4, (body) => {
|
|
44698
46857
|
try {
|
|
44699
46858
|
const { locationId: locationId2, industry } = JSON.parse(body);
|
|
44700
46859
|
if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
|
|
@@ -44709,12 +46868,7 @@ async function runDashboard(argv) {
|
|
|
44709
46868
|
return;
|
|
44710
46869
|
}
|
|
44711
46870
|
if (req.method === "POST" && url === "/api/account") {
|
|
44712
|
-
|
|
44713
|
-
req.on("data", (c) => {
|
|
44714
|
-
body += c;
|
|
44715
|
-
if (body.length > 1e4) req.destroy();
|
|
44716
|
-
});
|
|
44717
|
-
req.on("end", () => {
|
|
46871
|
+
readBody(req, res, 1e4, (body) => {
|
|
44718
46872
|
try {
|
|
44719
46873
|
const { locationId: locationId2, type } = JSON.parse(body);
|
|
44720
46874
|
if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
|
|
@@ -44729,12 +46883,7 @@ async function runDashboard(argv) {
|
|
|
44729
46883
|
return;
|
|
44730
46884
|
}
|
|
44731
46885
|
if (req.method === "POST" && url === "/api/stage") {
|
|
44732
|
-
|
|
44733
|
-
req.on("data", (c) => {
|
|
44734
|
-
body += c;
|
|
44735
|
-
if (body.length > 1e4) req.destroy();
|
|
44736
|
-
});
|
|
44737
|
-
req.on("end", () => {
|
|
46886
|
+
readBody(req, res, 1e4, (body) => {
|
|
44738
46887
|
try {
|
|
44739
46888
|
const { locationId: locationId2, stage, status, by } = JSON.parse(body);
|
|
44740
46889
|
if (!currentRoster().some((r) => r.locationId === locationId2)) throw new Error("unknown client");
|
|
@@ -44773,7 +46922,7 @@ async function runDashboard(argv) {
|
|
|
44773
46922
|
}
|
|
44774
46923
|
res.writeHead(404);
|
|
44775
46924
|
res.end();
|
|
44776
|
-
});
|
|
46925
|
+
}));
|
|
44777
46926
|
try {
|
|
44778
46927
|
await new Promise((resolve11, reject) => {
|
|
44779
46928
|
server2.once("error", reject);
|
|
@@ -44809,7 +46958,7 @@ async function runDashboard(argv) {
|
|
|
44809
46958
|
process.on("SIGTERM", stop);
|
|
44810
46959
|
});
|
|
44811
46960
|
}
|
|
44812
|
-
var fs32, path31, http, import_child_process5, import_child_process6, osmod, STAGES2, ACCOUNT_TYPES, activeRun2, UPGRADE_MSG, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG;
|
|
46961
|
+
var fs32, path31, http, import_child_process5, import_child_process6, osmod, STAGES2, ACCOUNT_TYPES, activeRun2, UPGRADE_MSG, DOC_EXTS, DOC_MAX_BYTES, NEEDS_AGENCY_KEY, INTAKE_OWN_MESSAGES, currentRosterRef, BOARD_ROUTES, BOARD_TITLES, anthropicKeyWrites, SEAT_TOKEN_HEADER, NOT_SIGNED_IN_MSG, SEAT_GUARDED_READS;
|
|
44813
46962
|
var init_dashboard = __esm({
|
|
44814
46963
|
"src/dashboard.ts"() {
|
|
44815
46964
|
"use strict";
|
|
@@ -44839,6 +46988,7 @@ var init_dashboard = __esm({
|
|
|
44839
46988
|
init_agency_profile_page();
|
|
44840
46989
|
init_cockpit_shell();
|
|
44841
46990
|
init_anthropic_key();
|
|
46991
|
+
init_app_html();
|
|
44842
46992
|
init_cli();
|
|
44843
46993
|
init_brand_page();
|
|
44844
46994
|
init_store();
|
|
@@ -44855,9 +47005,14 @@ var init_dashboard = __esm({
|
|
|
44855
47005
|
init_stage_runner();
|
|
44856
47006
|
import_child_process6 = require("child_process");
|
|
44857
47007
|
init_ghl_client();
|
|
47008
|
+
init_client_capabilities();
|
|
47009
|
+
init_capability_runs();
|
|
47010
|
+
init_assessment();
|
|
44858
47011
|
init_shared_brain();
|
|
44859
47012
|
init_work_queue();
|
|
44860
47013
|
osmod = __toESM(require("os"));
|
|
47014
|
+
init_script_json();
|
|
47015
|
+
init_svg_sanitize();
|
|
44861
47016
|
STAGES2 = [
|
|
44862
47017
|
"Intake",
|
|
44863
47018
|
"Brief",
|
|
@@ -44870,9 +47025,22 @@ var init_dashboard = __esm({
|
|
|
44870
47025
|
ACCOUNT_TYPES = ["client", "sandbox", "template", "hidden"];
|
|
44871
47026
|
activeRun2 = null;
|
|
44872
47027
|
UPGRADE_MSG = "\n The cockpit is not included in this licence, so it did not start.\n Nothing was changed.\n\n";
|
|
47028
|
+
DOC_EXTS = /* @__PURE__ */ new Set([".pdf", ".docx", ".doc", ".md", ".txt", ".rtf", ".csv"]);
|
|
47029
|
+
DOC_MAX_BYTES = 20 * 1024 * 1024;
|
|
47030
|
+
NEEDS_AGENCY_KEY = "No agency key on file. Run register_agency_key in GHL Command first.";
|
|
47031
|
+
INTAKE_OWN_MESSAGES = ["That account is not on the board.", "this client isn't connected yet \u2014 paste its key first"];
|
|
47032
|
+
currentRosterRef = () => [];
|
|
47033
|
+
BOARD_ROUTES = { "/": "board", "/new": "new", "/queue": "queue", "/clients": "clients" };
|
|
47034
|
+
BOARD_TITLES = {
|
|
47035
|
+
board: "Command OS \u2014 GHL Command",
|
|
47036
|
+
new: "Build a Client Account \u2014 Command OS",
|
|
47037
|
+
queue: "Work Queue \u2014 Command OS",
|
|
47038
|
+
clients: "Client Builds \u2014 Command OS"
|
|
47039
|
+
};
|
|
44873
47040
|
anthropicKeyWrites = Promise.resolve();
|
|
44874
47041
|
SEAT_TOKEN_HEADER = "x-command-os-seat";
|
|
44875
47042
|
NOT_SIGNED_IN_MSG = "This browser isn't signed in to Command OS on this computer. Open Command OS on the machine it runs on, and use the page it opens for you.";
|
|
47043
|
+
SEAT_GUARDED_READS = ["/api/capability", "/api/client-intake", "/api/intake-documents", "/api/snapshots"];
|
|
44876
47044
|
}
|
|
44877
47045
|
});
|
|
44878
47046
|
|
|
@@ -45432,7 +47600,7 @@ function preflightWritable() {
|
|
|
45432
47600
|
}
|
|
45433
47601
|
}
|
|
45434
47602
|
function restartReminder() {
|
|
45435
|
-
errLine("Note: if
|
|
47603
|
+
errLine("Note: if GHL Command is currently running, restart it to pick up this change");
|
|
45436
47604
|
errLine("(registry writes are last-writer-wins across processes \u2014 seed stopped, or restart after).");
|
|
45437
47605
|
}
|
|
45438
47606
|
function confirmSaved(registry2) {
|
|
@@ -45741,7 +47909,7 @@ Subcommands:
|
|
|
45741
47909
|
list-locations Print registered locations / companies (never prints keys)
|
|
45742
47910
|
|
|
45743
47911
|
Exit codes: 0 ok, 2 usage, 3 validation failed, 4 filesystem write failed.
|
|
45744
|
-
Seed while
|
|
47912
|
+
Seed while GHL Command is stopped, or restart it afterwards.`;
|
|
45745
47913
|
}
|
|
45746
47914
|
});
|
|
45747
47915
|
|
|
@@ -45750,7 +47918,7 @@ var require_package = __commonJS({
|
|
|
45750
47918
|
"package.json"(exports2, module2) {
|
|
45751
47919
|
module2.exports = {
|
|
45752
47920
|
name: "@elitedcs/ghl-mcp",
|
|
45753
|
-
version: "3.
|
|
47921
|
+
version: "3.82.0",
|
|
45754
47922
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
45755
47923
|
description: "GoHighLevel MCP Server for Claude. 250 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
|
|
45756
47924
|
main: "dist/index.js",
|
|
@@ -45774,7 +47942,7 @@ var require_package = __commonJS({
|
|
|
45774
47942
|
"!skills/blueprint/presets/clinic-launch-a2p.md"
|
|
45775
47943
|
],
|
|
45776
47944
|
scripts: {
|
|
45777
|
-
build: "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --packages=external && esbuild src/capture-helper.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/capture-helper.js --packages=external
|
|
47945
|
+
build: "node src/command-os/assessment/build-app.mjs && esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --packages=external && esbuild src/capture-helper.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/capture-helper.js --packages=external",
|
|
45778
47946
|
setup: "node setup-wizard.mjs",
|
|
45779
47947
|
catalogue: "node scripts/export-command-os-catalogue.mjs",
|
|
45780
47948
|
"manifest:write": "node scripts/write-package-manifest.mjs",
|
|
@@ -45828,6 +47996,7 @@ var require_package = __commonJS({
|
|
|
45828
47996
|
"@clack/prompts": "^1.1.0",
|
|
45829
47997
|
"@types/node": "^26.2.0",
|
|
45830
47998
|
esbuild: "^0.28.1",
|
|
47999
|
+
"happy-dom": "^20.14.0",
|
|
45831
48000
|
open: "^11.0.0",
|
|
45832
48001
|
typescript: "^7.0.2",
|
|
45833
48002
|
vitest: "^4.1.6"
|
|
@@ -46218,6 +48387,11 @@ function agencyPrimer(p) {
|
|
|
46218
48387
|
if (staff.length) lines.push(`Their team: ${staff.join("; ")}.`);
|
|
46219
48388
|
const sops = (p.delivery?.sops ?? []).filter(Boolean);
|
|
46220
48389
|
if (sops.length) lines.push(`How they deliver, in their words: ${sops.map((s) => `"${s}"`).join(" ")}`);
|
|
48390
|
+
const os7 = p.agencyOs;
|
|
48391
|
+
if (os7?.method) lines.push(`Their method: ${os7.method}`);
|
|
48392
|
+
if (os7?.voice) lines.push(`How they sound, and how you should sound for them: ${os7.voice}`);
|
|
48393
|
+
if (os7?.ica?.who) lines.push(`Who they serve: ${os7.ica.who}`);
|
|
48394
|
+
if (os7?.offer?.name || os7?.offer?.promise) lines.push(`Their core offer: ${[os7.offer?.name, os7.offer?.promise].filter(Boolean).join(" \u2014 ")}`);
|
|
46221
48395
|
if (!lines.length) return void 0;
|
|
46222
48396
|
lines.push("These are the agency's own details, not any client's. Client-by-client records are a tool call away, not in here.");
|
|
46223
48397
|
const text = lines.join("\n");
|
|
@@ -46241,7 +48415,7 @@ init_version_check();
|
|
|
46241
48415
|
function registerMetaTools(server2, installedVersion) {
|
|
46242
48416
|
server2.tool(
|
|
46243
48417
|
"get_mcp_version",
|
|
46244
|
-
"Check the installed version of GHL Command
|
|
48418
|
+
"Check the installed version of GHL Command against the latest version published to npm. Use this to confirm an upgrade landed after restarting Claude. Returns installed version, latest version, whether up-to-date, and the one-line restart instruction if not.",
|
|
46245
48419
|
{},
|
|
46246
48420
|
async () => {
|
|
46247
48421
|
const status = await getVersionStatus(installedVersion);
|