@fleox-agent-crm/agentcrm 0.0.0-stage → 0.3.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/LICENSE +143 -0
- package/README.md +223 -2
- package/changelog.json +57 -0
- package/dist/admin/admin-files.json +26 -0
- package/dist/admin/assets/chartsImpl-CE2akJat.js +91 -0
- package/dist/admin/assets/firebase-BHSUggRo.js +2 -0
- package/dist/admin/assets/gitJson-BulGh2KV.js +3 -0
- package/dist/admin/assets/index-DidVVK7L.js +1181 -0
- package/dist/admin/assets/index-hMXwHSvN.css +1 -0
- package/dist/admin/assets/index.esm-BFc7vwnl.js +400 -0
- package/dist/admin/assets/index.esm-CtEQZRb1.js +1651 -0
- package/dist/admin/assets/index.esm-Dum0J07z.js +16 -0
- package/dist/admin/assets/index.esm2017-EOzKbpUS.js +390 -0
- package/dist/admin/assets/lucide-react.min-DEDlHCPj.js +6 -0
- package/dist/admin/assets/sdk-CnrLnlRo.js +1717 -0
- package/dist/admin/config.js +3 -0
- package/dist/admin/favicon.svg +1 -0
- package/dist/admin/index.html +139 -0
- package/dist/admin/runtime/agentcrm-runtime.js +104 -0
- package/dist/admin/runtime/lucide-react.js +791 -0
- package/dist/admin/runtime/react-dom-client.js +17 -0
- package/dist/admin/runtime/react-dom.js +20 -0
- package/dist/admin/runtime/react-i18next.js +19 -0
- package/dist/admin/runtime/react-jsx-runtime.js +17 -0
- package/dist/admin/runtime/react.js +22 -0
- package/dist/agent/agentcrm.mjs +3932 -0
- package/dist/agent/chunks/_commonjsHelpers-ClciMIcB.mjs +14 -0
- package/dist/agent/chunks/activities-Cj_SIOay.mjs +227 -0
- package/dist/agent/chunks/api-B6OqNVWp.mjs +97 -0
- package/dist/agent/chunks/appearance-BaShelzt.mjs +174 -0
- package/dist/agent/chunks/automations-BWrvLqP6.mjs +698 -0
- package/dist/agent/chunks/backup-ClSmxmFZ.mjs +103 -0
- package/dist/agent/chunks/batch-CVETiJj5.mjs +142 -0
- package/dist/agent/chunks/compact-CsFPaAAq.mjs +69 -0
- package/dist/agent/chunks/context-CxFfqS4D.mjs +65 -0
- package/dist/agent/chunks/core-Dy7vY9H6.mjs +255 -0
- package/dist/agent/chunks/dedupe-BepmsaHv.mjs +123 -0
- package/dist/agent/chunks/encoding-C9MzdP95.mjs +36 -0
- package/dist/agent/chunks/exportCsv-B5hRvkNm.mjs +95 -0
- package/dist/agent/chunks/extCheck--7lmVvl7.mjs +193 -0
- package/dist/agent/chunks/extPackage-BGl5Oqs8.mjs +115 -0
- package/dist/agent/chunks/extScaffold-DqxI7R1e.mjs +320 -0
- package/dist/agent/chunks/extensionActions-D2HtpQz4.mjs +48 -0
- package/dist/agent/chunks/extensions-BP1k60md.mjs +151 -0
- package/dist/agent/chunks/featureRegistry-C9nbVShp.mjs +149 -0
- package/dist/agent/chunks/fields-B0bitgmr.mjs +476 -0
- package/dist/agent/chunks/files-CjsPJSR6.mjs +158 -0
- package/dist/agent/chunks/files-q8Xu5bft.mjs +367 -0
- package/dist/agent/chunks/firebase-DxGVQV9E.mjs +454 -0
- package/dist/agent/chunks/firebaseRules-iori-s7U.mjs +221 -0
- package/dist/agent/chunks/forms-BKU5F9WF.mjs +847 -0
- package/dist/agent/chunks/gitJson-fjr8mcHe.mjs +528 -0
- package/dist/agent/chunks/hosting-Bme7LxPc.mjs +144 -0
- package/dist/agent/chunks/http-Bk9ICb7I.mjs +127 -0
- package/dist/agent/chunks/ids-CvnmrMJX.mjs +59 -0
- package/dist/agent/chunks/ids-u-noYcy5.mjs +14 -0
- package/dist/agent/chunks/importCsv-DmE_sBch.mjs +334 -0
- package/dist/agent/chunks/index-B-4W0-P4.mjs +535 -0
- package/dist/agent/chunks/index-BM8qeGOb.mjs +2489 -0
- package/dist/agent/chunks/index-Bnmav-Oh.mjs +311 -0
- package/dist/agent/chunks/index-CRkw3_iF.mjs +147 -0
- package/dist/agent/chunks/index-CWrrF1wl.mjs +111 -0
- package/dist/agent/chunks/index-DUSdTBkz.mjs +2988 -0
- package/dist/agent/chunks/index-RCmo-uld.mjs +26 -0
- package/dist/agent/chunks/index-w03ReRs4.mjs +9780 -0
- package/dist/agent/chunks/install-CpHoUFtL.mjs +188 -0
- package/dist/agent/chunks/jsx-runtime-Ca2uwGDr.mjs +76 -0
- package/dist/agent/chunks/loader-C9nm3iwx.mjs +184 -0
- package/dist/agent/chunks/lucide-react.min-Bt3gtepY.mjs +64 -0
- package/dist/agent/chunks/memory-WNR3VHUJ.mjs +56 -0
- package/dist/agent/chunks/migrate-CsClnnN8.mjs +182 -0
- package/dist/agent/chunks/modules-CqdVQVJb.mjs +177 -0
- package/dist/agent/chunks/nodeImport-BPCt5uMu.mjs +38 -0
- package/dist/agent/chunks/package-DWbn3t2R.mjs +194 -0
- package/dist/agent/chunks/papaparse-DjBYnQM2.mjs +1285 -0
- package/dist/agent/chunks/pipelines-DCdRBNBV.mjs +234 -0
- package/dist/agent/chunks/plan-C0TBrU92.mjs +254 -0
- package/dist/agent/chunks/pluginRegistry-DyHtlkcr.mjs +83 -0
- package/dist/agent/chunks/query-C5nJB_3C.mjs +192 -0
- package/dist/agent/chunks/records-DoqiT9xR.mjs +2191 -0
- package/dist/agent/chunks/registry-Ceexawxl.mjs +182 -0
- package/dist/agent/chunks/registry-DijAKZ24.mjs +431 -0
- package/dist/agent/chunks/relations-B9TynBmA.mjs +191 -0
- package/dist/agent/chunks/reports-BB59N6ke.mjs +189 -0
- package/dist/agent/chunks/runtime-DHo7ZEEO.mjs +14852 -0
- package/dist/agent/chunks/runtimeConfig-CCOFg1RM.mjs +250 -0
- package/dist/agent/chunks/s3-Dz50-NuA.mjs +903 -0
- package/dist/agent/chunks/schema-7-4-qtSS.mjs +203 -0
- package/dist/agent/chunks/schema-DlHt2PxI.mjs +367 -0
- package/dist/agent/chunks/sdk-whvfg786.mjs +43644 -0
- package/dist/agent/chunks/seed-CugPGYZL.mjs +523 -0
- package/dist/agent/chunks/server-ISwlDRCN.mjs +242 -0
- package/dist/agent/chunks/settings-BtvR_jdm.mjs +76 -0
- package/dist/agent/chunks/standardObjects-CheqfDqa.mjs +243 -0
- package/dist/agent/chunks/stats-CfeJssSS.mjs +51 -0
- package/dist/agent/chunks/store-5gqRSKXg.mjs +122 -0
- package/dist/agent/chunks/themes-DPZFv3yc.mjs +43 -0
- package/dist/agent/chunks/tools-Bew8lxKx.mjs +844 -0
- package/dist/agent/chunks/types-BOy9PUl7.mjs +44 -0
- package/dist/agent/chunks/users-DI1BvS0f.mjs +107 -0
- package/dist/agent/chunks/utils-DmI8FtXP.mjs +83 -0
- package/dist/agent/chunks/views-fO_BM5U_.mjs +109 -0
- package/dist/version.json +7 -0
- package/package.json +97 -4
- package/plugin/.claude-plugin/plugin.json +17 -0
- package/plugin/skills/agentcrm-admin/SKILL.md +191 -0
- package/plugin/skills/agentcrm-admin/references/appearance.md +26 -0
- package/plugin/skills/agentcrm-admin/references/import.md +16 -0
- package/plugin/skills/agentcrm-admin/references/records.md +30 -0
- package/plugin/skills/agentcrm-admin/references/schema.md +59 -0
- package/plugin/skills/agentcrm-extensions/SKILL.md +93 -0
- package/plugin/skills/agentcrm-extensions/references/module-recipes.md +213 -0
- package/plugin/skills/agentcrm-extensions/references/runtime-api.md +41 -0
- package/plugin/skills/agentcrm-extensions/references/testing.md +76 -0
- package/plugin/skills/agentcrm-extensions/references/theme-recipes.md +65 -0
- package/plugin/skills/agentcrm-install/SKILL.md +116 -0
- package/plugin/skills/agentcrm-install/references/firebase.md +18 -0
- package/plugin/skills/agentcrm-install/references/github.md +20 -0
- package/plugin/skills/agentcrm-install/references/mcp.md +17 -0
- package/plugin/skills/agentcrm-install/references/plans.md +45 -0
- package/plugin/skills/agentcrm-install/references/s3.md +24 -0
- package/plugin/skills/agentcrm-manage/SKILL.md +108 -0
- package/plugin/skills/agentcrm-manage/references/cron.md +11 -0
- package/plugin/skills/agentcrm-manage/references/doctor.md +18 -0
- package/plugin/skills/agentcrm-sales/SKILL.md +92 -0
- package/plugin/skills/agentcrm-sales/references/enrichment.md +13 -0
- package/plugin/skills/agentcrm-sales/references/playbooks.md +29 -0
- package/plugin/skills/agentcrm-sales/references/weekly-report.md +27 -0
- package/types/agentcrm-runtime.d.ts +894 -0
|
@@ -0,0 +1,847 @@
|
|
|
1
|
+
import { createRequire as __acrmRequire } from "node:module";
|
|
2
|
+
import { fileURLToPath as __acrmPath } from "node:url";
|
|
3
|
+
import { dirname as __acrmDirname } from "node:path";
|
|
4
|
+
const require2 = __acrmRequire(import.meta.url);
|
|
5
|
+
const __filename = __acrmPath(import.meta.url);
|
|
6
|
+
const __dirname = __acrmDirname(__filename);
|
|
7
|
+
import { normalizeEmail, isEmptyFieldValue, normalizeDomain, formatFieldValue, recordLabel } from "./fields-B0bitgmr.mjs";
|
|
8
|
+
import { n as newId } from "./ids-CvnmrMJX.mjs";
|
|
9
|
+
import { addActivity } from "./activities-Cj_SIOay.mjs";
|
|
10
|
+
import { assertCan, OpsError } from "./context-CxFfqS4D.mjs";
|
|
11
|
+
import { createRecord, updateRecord, queryContext } from "./records-DoqiT9xR.mjs";
|
|
12
|
+
import { formsOf, requireObject, asDoc, objectOf, thaw, userOf, recordsOf, activitiesOf } from "./store-5gqRSKXg.mjs";
|
|
13
|
+
import "./query-C5nJB_3C.mjs";
|
|
14
|
+
import "../agentcrm.mjs";
|
|
15
|
+
import "node:util";
|
|
16
|
+
import "node:fs";
|
|
17
|
+
import "node:path";
|
|
18
|
+
import "node:url";
|
|
19
|
+
import "node:crypto";
|
|
20
|
+
import "node:os";
|
|
21
|
+
import "node:child_process";
|
|
22
|
+
import "node:http";
|
|
23
|
+
import "./relations-B9TynBmA.mjs";
|
|
24
|
+
import "./pluginRegistry-DyHtlkcr.mjs";
|
|
25
|
+
import "./standardObjects-CheqfDqa.mjs";
|
|
26
|
+
const INBOX_KINDS = ["s3", "firebase", "webhook"];
|
|
27
|
+
const DEFAULT_INBOX_PREFIX = "inbox/";
|
|
28
|
+
const DEFAULT_FIREBASE_INBOX = "inbox";
|
|
29
|
+
const DEFAULT_FORM_TEXTS = {
|
|
30
|
+
submit: "Submit",
|
|
31
|
+
sending: "Sending…",
|
|
32
|
+
error: "Something went wrong. Please try again in a moment.",
|
|
33
|
+
tooFast: "Please take a second to check your answers, then submit again."
|
|
34
|
+
};
|
|
35
|
+
const MIN_FILL_MS = 2500;
|
|
36
|
+
const HONEYPOT_NAME = "_hp";
|
|
37
|
+
const FORM_FIELD_TYPES = [
|
|
38
|
+
"text",
|
|
39
|
+
"longText",
|
|
40
|
+
"email",
|
|
41
|
+
"phone",
|
|
42
|
+
"url",
|
|
43
|
+
"number",
|
|
44
|
+
"currency",
|
|
45
|
+
"percent",
|
|
46
|
+
"date",
|
|
47
|
+
"datetime",
|
|
48
|
+
"boolean",
|
|
49
|
+
"select",
|
|
50
|
+
"multiSelect",
|
|
51
|
+
"rating",
|
|
52
|
+
"tags"
|
|
53
|
+
];
|
|
54
|
+
function isFormFieldType(type) {
|
|
55
|
+
return FORM_FIELD_TYPES.includes(type);
|
|
56
|
+
}
|
|
57
|
+
function escapeHtml(s) {
|
|
58
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
59
|
+
}
|
|
60
|
+
function scriptJson(value) {
|
|
61
|
+
return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
62
|
+
}
|
|
63
|
+
function safeColor(c) {
|
|
64
|
+
return c && /^#[0-9a-f]{3,8}$/i.test(c) ? c : "#2563eb";
|
|
65
|
+
}
|
|
66
|
+
function inputFor(def, name, id, required, describedBy) {
|
|
67
|
+
const req = required ? ' required aria-required="true"' : "";
|
|
68
|
+
const common = `id="${id}" name="${escapeHtml(name)}"${req} aria-describedby="${describedBy}"`;
|
|
69
|
+
switch (def.type) {
|
|
70
|
+
case "longText":
|
|
71
|
+
return `<textarea ${common} rows="4"></textarea>`;
|
|
72
|
+
case "email":
|
|
73
|
+
return `<input type="email" ${common} autocomplete="email" inputmode="email">`;
|
|
74
|
+
case "phone":
|
|
75
|
+
return `<input type="tel" ${common} autocomplete="tel" inputmode="tel">`;
|
|
76
|
+
case "url":
|
|
77
|
+
return `<input type="url" ${common} autocomplete="url" placeholder="https://">`;
|
|
78
|
+
case "number":
|
|
79
|
+
case "currency":
|
|
80
|
+
case "percent":
|
|
81
|
+
return `<input type="number" ${common} step="any" inputmode="decimal">`;
|
|
82
|
+
case "rating":
|
|
83
|
+
return `<input type="number" ${common} min="1" max="5" step="1" inputmode="numeric">`;
|
|
84
|
+
case "date":
|
|
85
|
+
return `<input type="date" ${common}>`;
|
|
86
|
+
case "datetime":
|
|
87
|
+
return `<input type="datetime-local" ${common}>`;
|
|
88
|
+
case "select":
|
|
89
|
+
return `<select ${common}><option value="">—</option>${(def.options ?? []).map((o) => `<option value="${escapeHtml(o.id)}">${escapeHtml(o.label)}</option>`).join("")}</select>`;
|
|
90
|
+
default: {
|
|
91
|
+
const ac = name === "firstName" ? ' autocomplete="given-name"' : name === "lastName" ? ' autocomplete="family-name"' : name === "name" ? ' autocomplete="name"' : name === "jobTitle" ? ' autocomplete="organization-title"' : name === "city" ? ' autocomplete="address-level2"' : "";
|
|
92
|
+
return `<input type="text" ${common}${ac}>`;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function fieldBlock(form, object, index) {
|
|
97
|
+
const f = form.fields[index];
|
|
98
|
+
const def = object.fields.find((x) => x.id === f.field);
|
|
99
|
+
if (!def || !isFormFieldType(def.type)) return "";
|
|
100
|
+
const id = `acrm-${form.id}-${index}`;
|
|
101
|
+
const label = escapeHtml(f.label || def.label);
|
|
102
|
+
const star = f.required ? ' <span class="acrm-req" aria-hidden="true">*</span>' : "";
|
|
103
|
+
const err = `${id}-err`;
|
|
104
|
+
if (def.type === "boolean") {
|
|
105
|
+
return `<div class="acrm-field acrm-check"><input type="checkbox" id="${id}" name="${escapeHtml(def.id)}" value="true"${f.required ? ' required aria-required="true"' : ""} aria-describedby="${err}"><label for="${id}">${label}${star}</label><p class="acrm-err" id="${err}" hidden></p></div>`;
|
|
106
|
+
}
|
|
107
|
+
if (def.type === "multiSelect") {
|
|
108
|
+
const opts = (def.options ?? []).map((o, i) => `<label class="acrm-opt"><input type="checkbox" name="${escapeHtml(def.id)}" value="${escapeHtml(o.id)}" id="${id}-${i}"> ${escapeHtml(o.label)}</label>`).join("");
|
|
109
|
+
return `<fieldset class="acrm-field" aria-describedby="${err}"${f.required ? ' data-required="true"' : ""}><legend>${label}${star}</legend>${opts}<p class="acrm-err" id="${err}" hidden></p></fieldset>`;
|
|
110
|
+
}
|
|
111
|
+
const hint = def.type === "tags" ? `<p class="acrm-hint" id="${id}-hint">Separate values with commas.</p>` : "";
|
|
112
|
+
return `<div class="acrm-field"><label for="${id}">${label}${star}</label>${inputFor(def, def.id, id, f.required, def.type === "tags" ? `${err} ${id}-hint` : err)}${hint}<p class="acrm-err" id="${err}" hidden></p></div>`;
|
|
113
|
+
}
|
|
114
|
+
function formClientConfig(form, object) {
|
|
115
|
+
const s = form.inbox.settings ?? {};
|
|
116
|
+
let endpoint = "";
|
|
117
|
+
let extra;
|
|
118
|
+
if (form.inbox.kind === "s3") {
|
|
119
|
+
endpoint = (s.uploadUrl ?? "").trim();
|
|
120
|
+
if (endpoint && !endpoint.endsWith("/")) endpoint += "/";
|
|
121
|
+
} else if (form.inbox.kind === "firebase") {
|
|
122
|
+
const collection = (s.collection || DEFAULT_FIREBASE_INBOX).trim();
|
|
123
|
+
endpoint = `https://firestore.googleapis.com/v1/projects/${encodeURIComponent((s.projectId ?? "").trim())}/databases/(default)/documents/${encodeURIComponent(collection)}?key=${encodeURIComponent((s.apiKey ?? "").trim())}`;
|
|
124
|
+
} else {
|
|
125
|
+
endpoint = (s.url ?? "").trim();
|
|
126
|
+
if (s.extra) {
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(s.extra);
|
|
129
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) extra = parsed;
|
|
130
|
+
} catch {
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const fields = form.fields.map((f) => object.fields.find((x) => x.id === f.field)).filter((d) => !!d && isFormFieldType(d.type)).map((d) => ({ name: d.id, type: d.type }));
|
|
135
|
+
return {
|
|
136
|
+
formId: form.id,
|
|
137
|
+
kind: form.inbox.kind,
|
|
138
|
+
endpoint,
|
|
139
|
+
...form.inbox.kind === "webhook" ? { format: s.format === "form" ? "form" : "json" } : {},
|
|
140
|
+
...extra ? { extra } : {},
|
|
141
|
+
successMessage: form.successMessage || "Thank you! We'll be in touch soon.",
|
|
142
|
+
...form.redirectUrl ? { redirectUrl: form.redirectUrl } : {},
|
|
143
|
+
texts: { ...DEFAULT_FORM_TEXTS, ...Object.fromEntries(Object.entries(form.texts ?? {}).filter(([k, v]) => k in DEFAULT_FORM_TEXTS && typeof v === "string" && v.trim())) },
|
|
144
|
+
fields,
|
|
145
|
+
minFillMs: MIN_FILL_MS
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function formScript(config) {
|
|
149
|
+
return `(function(){
|
|
150
|
+
var C=${scriptJson(config)};
|
|
151
|
+
function enc(v){if(typeof v==="string")return{stringValue:v};if(typeof v==="boolean")return{booleanValue:v};if(typeof v==="number")return{doubleValue:v};if(Array.isArray(v))return{arrayValue:{values:v.map(enc)}};if(v===null||v===undefined)return{nullValue:null};var f={};for(var k in v)f[k]=enc(v[k]);return{mapValue:{fields:f}};}
|
|
152
|
+
function rid(){var a=new Uint8Array(6);(window.crypto||{getRandomValues:function(x){for(var i=0;i<x.length;i++)x[i]=Math.random()*256|0;return x;}}).getRandomValues(a);return Array.prototype.map.call(a,function(b){return("0"+b.toString(16)).slice(-2)}).join("");}
|
|
153
|
+
function collect(form){var data={};C.fields.forEach(function(f){var els=form.querySelectorAll('[name="'+f.name+'"]');if(!els.length)return;if(f.type==="multiSelect"){var vals=[];els.forEach(function(e){if(e.checked)vals.push(e.value)});if(vals.length)data[f.name]=vals;return;}var el=els[0];if(f.type==="boolean"){data[f.name]=!!el.checked;return;}var v=(el.value||"").trim();if(!v)return;if(f.type==="tags"){data[f.name]=v.split(",").map(function(s){return s.trim()}).filter(Boolean);return;}if(f.type==="number"||f.type==="currency"||f.type==="percent"||f.type==="rating"){var n=Number(v);data[f.name]=isNaN(n)?v:n;return;}data[f.name]=v;});return data;}
|
|
154
|
+
function send(sub){var headers={};if(C.preview)return Promise.resolve({ok:true,status:200});if(C.kind==="s3"){return fetch(C.endpoint+encodeURIComponent(C.formId)+"/"+sub.createdAt+"-"+rid()+".json",{method:"PUT",headers:{"content-type":"application/json"},body:JSON.stringify(sub)});}
|
|
155
|
+
if(C.kind==="firebase"){return fetch(C.endpoint,{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({fields:{formId:enc(sub.formId),data:enc(sub.data),createdAt:{integerValue:String(sub.createdAt)}}})});}
|
|
156
|
+
var flat={};for(var k in sub.data)flat[k]=sub.data[k];if(C.extra)for(var x in C.extra)flat[x]=C.extra[x];flat._formId=sub.formId;flat._submittedAt=new Date(sub.createdAt).toISOString();flat._page=sub.meta.page;
|
|
157
|
+
if(C.format==="form"){var p=new URLSearchParams();for(var y in flat){var val=flat[y];p.append(y,Array.isArray(val)?val.join(", "):String(val));}return fetch(C.endpoint,{method:"POST",body:p});}
|
|
158
|
+
headers["content-type"]="application/json";headers.accept="application/json";return fetch(C.endpoint,{method:"POST",headers:headers,body:JSON.stringify(flat)});}
|
|
159
|
+
function bind(form){if(form.__acrm)return;form.__acrm=true;var t0=Date.now();var status=form.querySelector(".acrm-status");var btn=form.querySelector('button[type="submit"]');var label=btn?btn.querySelector(".acrm-btn-label"):null;
|
|
160
|
+
function say(msg,isErr){if(!status)return;status.textContent=msg;status.className="acrm-status"+(isErr?" acrm-status-err":"");status.hidden=!msg;}
|
|
161
|
+
form.addEventListener("submit",function(e){e.preventDefault();say("");
|
|
162
|
+
var groups=form.querySelectorAll('fieldset[data-required="true"]');for(var i=0;i<groups.length;i++){if(!groups[i].querySelector("input:checked")){var er=groups[i].querySelector(".acrm-err");if(er){er.textContent="Please choose at least one option.";er.hidden=false;}groups[i].querySelector("input").focus();return;}}
|
|
163
|
+
if(form.reportValidity&&!form.reportValidity())return;
|
|
164
|
+
var hp=form.querySelector('[name="${HONEYPOT_NAME}"]');if(hp&&hp.value){form.innerHTML='<p class="acrm-success" role="status"></p>';form.firstChild.textContent=C.successMessage;return;}
|
|
165
|
+
if(Date.now()-t0<C.minFillMs){say(C.texts.tooFast,true);return;}
|
|
166
|
+
var sub={formId:C.formId,data:collect(form),createdAt:Date.now(),meta:{page:location.href,referrer:document.referrer||"",elapsedMs:Date.now()-t0,lang:navigator.language||""}};
|
|
167
|
+
if(btn){btn.disabled=true;btn.setAttribute("aria-busy","true");}if(label)label.textContent=C.texts.sending;
|
|
168
|
+
send(sub).then(function(r){if(r&&r.type!=="opaque"&&!r.ok)throw new Error("HTTP "+r.status);if(C.redirectUrl){location.href=C.redirectUrl;return;}var p=document.createElement("p");p.className="acrm-success";p.setAttribute("role","status");p.setAttribute("tabindex","-1");p.textContent=C.successMessage;form.replaceChildren(p);p.focus();}).catch(function(){say(C.texts.error,true);if(btn){btn.disabled=false;btn.removeAttribute("aria-busy");}if(label)label.textContent=C.texts.submit;});});}
|
|
169
|
+
function init(){document.querySelectorAll('form[data-agentcrm-form="'+C.formId+'"]').forEach(bind);}
|
|
170
|
+
if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",init);else init();
|
|
171
|
+
})();`;
|
|
172
|
+
}
|
|
173
|
+
function formMarkup(form, object) {
|
|
174
|
+
const texts = { ...DEFAULT_FORM_TEXTS, ...form.texts ?? {} };
|
|
175
|
+
const fields = form.fields.map((_f, i) => fieldBlock(form, object, i)).join("\n ");
|
|
176
|
+
return `<form class="acrm-form" data-agentcrm-form="${escapeHtml(form.id)}" data-form-type="other">
|
|
177
|
+
${fields}
|
|
178
|
+
<div class="acrm-hp" aria-hidden="true"><label for="acrm-${escapeHtml(form.id)}-hp">Leave this field empty</label><input type="text" id="acrm-${escapeHtml(form.id)}-hp" name="${HONEYPOT_NAME}" tabindex="-1" autocomplete="off"></div>
|
|
179
|
+
<p class="acrm-status" role="status" aria-live="polite" hidden></p>
|
|
180
|
+
<button type="submit" class="acrm-btn"><span class="acrm-btn-icon" aria-hidden="true"></span><span class="acrm-btn-label">${escapeHtml(texts.submit)}</span></button>
|
|
181
|
+
</form>`;
|
|
182
|
+
}
|
|
183
|
+
function formCss(accent, scope) {
|
|
184
|
+
return `${scope}{--acrm-accent:${accent};font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:#111827;box-sizing:border-box}
|
|
185
|
+
${scope} *{box-sizing:inherit}
|
|
186
|
+
${scope} .acrm-field{margin:0 0 1rem;border:0;padding:0}
|
|
187
|
+
${scope} label,${scope} legend{display:block;font-weight:600;font-size:.9rem;margin:0 0 .35rem}
|
|
188
|
+
${scope} .acrm-req{color:#b91c1c}
|
|
189
|
+
${scope} input[type=text],${scope} input[type=email],${scope} input[type=tel],${scope} input[type=url],${scope} input[type=number],${scope} input[type=date],${scope} input[type=datetime-local],${scope} select,${scope} textarea{width:100%;font:inherit;font-size:1rem;padding:.6rem .75rem;border:1px solid #d1d5db;border-radius:.5rem;background:#fff;color:inherit}
|
|
190
|
+
${scope} input:focus,${scope} select:focus,${scope} textarea:focus{outline:2px solid var(--acrm-accent);outline-offset:1px;border-color:var(--acrm-accent)}
|
|
191
|
+
${scope} .acrm-check{display:flex;gap:.5rem;align-items:flex-start}
|
|
192
|
+
${scope} .acrm-check label{font-weight:500;margin:0}
|
|
193
|
+
${scope} .acrm-opt{display:flex;gap:.5rem;align-items:center;font-weight:400;margin:.25rem 0}
|
|
194
|
+
${scope} .acrm-hint{font-size:.8rem;color:#6b7280;margin:.25rem 0 0}
|
|
195
|
+
${scope} .acrm-err,${scope} .acrm-status-err{color:#b91c1c;font-size:.85rem;margin:.25rem 0 0}
|
|
196
|
+
${scope} .acrm-status{margin:0 0 1rem}
|
|
197
|
+
${scope} .acrm-hp{position:absolute!important;left:-10000px!important;width:1px;height:1px;overflow:hidden}
|
|
198
|
+
${scope} .acrm-btn{display:inline-flex;align-items:center;gap:.5rem;font:inherit;font-weight:600;font-size:1rem;padding:.7rem 1.4rem;border:0;border-radius:.5rem;background:var(--acrm-accent);color:#fff;cursor:pointer}
|
|
199
|
+
${scope} .acrm-btn:hover{filter:brightness(1.08)}
|
|
200
|
+
${scope} .acrm-btn:focus-visible{outline:3px solid var(--acrm-accent);outline-offset:2px}
|
|
201
|
+
${scope} .acrm-btn[disabled]{opacity:.6;cursor:progress}
|
|
202
|
+
${scope} .acrm-btn-icon:empty{display:none}
|
|
203
|
+
${scope} .acrm-success{font-size:1.05rem;padding:1rem;border-radius:.5rem;background:#ecfdf5;color:#065f46;outline:none}`;
|
|
204
|
+
}
|
|
205
|
+
function defaultFormPath(form) {
|
|
206
|
+
const slug = form.name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
|
|
207
|
+
return `forms/${slug || form.id}/index.html`;
|
|
208
|
+
}
|
|
209
|
+
function buildFormArtifacts(form, object, opts = {}) {
|
|
210
|
+
const config = { ...formClientConfig(form, object), ...opts.preview ? { preview: true, minFillMs: 0 } : {} };
|
|
211
|
+
const accent = safeColor(form.accentColor);
|
|
212
|
+
const title = form.texts?.title?.trim() || form.name;
|
|
213
|
+
const description = form.texts?.description?.trim();
|
|
214
|
+
const markup = formMarkup(form, object);
|
|
215
|
+
const script = formScript(config);
|
|
216
|
+
const html = `<!doctype html>
|
|
217
|
+
<html lang="${escapeHtml(opts.lang ?? "en")}">
|
|
218
|
+
<head>
|
|
219
|
+
<meta charset="utf-8">
|
|
220
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
221
|
+
<meta name="robots" content="noindex">
|
|
222
|
+
<meta name="generator" content="AgentCRM">
|
|
223
|
+
<title>${escapeHtml(title)}</title>
|
|
224
|
+
<style>
|
|
225
|
+
html{background:#f3f4f6}
|
|
226
|
+
body{margin:0;min-height:100vh;display:flex;align-items:flex-start;justify-content:center;padding:3rem 1rem}
|
|
227
|
+
main{width:100%;max-width:34rem;background:#fff;border-radius:1rem;box-shadow:0 1px 3px rgba(0,0,0,.08),0 8px 24px rgba(0,0,0,.06);padding:2rem}
|
|
228
|
+
h1{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:1.5rem;margin:0 0 .5rem;color:#111827}
|
|
229
|
+
.acrm-intro{font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:#4b5563;margin:0 0 1.5rem;line-height:1.5}
|
|
230
|
+
${formCss(accent, ".acrm-root")}
|
|
231
|
+
@media (prefers-color-scheme:dark){html{background:#111827}main{background:#1f2937}h1,.acrm-root{color:#f9fafb}.acrm-intro{color:#d1d5db}.acrm-root input,.acrm-root select,.acrm-root textarea{background:#111827!important;border-color:#374151!important}.acrm-root .acrm-success{background:#064e3b;color:#d1fae5}}
|
|
232
|
+
</style>
|
|
233
|
+
</head>
|
|
234
|
+
<body>
|
|
235
|
+
<main class="acrm-root">
|
|
236
|
+
<h1>${escapeHtml(title)}</h1>
|
|
237
|
+
${description ? `<p class="acrm-intro">${escapeHtml(description)}</p>
|
|
238
|
+
` : ""}${markup}
|
|
239
|
+
</main>
|
|
240
|
+
<script>${script}<\/script>
|
|
241
|
+
</body>
|
|
242
|
+
</html>
|
|
243
|
+
`;
|
|
244
|
+
const scope = `.acrm-embed-${form.id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
|
245
|
+
const embedSnippet = `<!-- AgentCRM lead form: ${escapeHtml(form.name).replace(/--/g, "—")} -->
|
|
246
|
+
<div class="${scope.slice(1)}">
|
|
247
|
+
<style>${formCss(accent, scope)}</style>
|
|
248
|
+
${markup}
|
|
249
|
+
</div>
|
|
250
|
+
<script>${script}<\/script>
|
|
251
|
+
`;
|
|
252
|
+
return { html, embedSnippet, path: form.publishedPath || defaultFormPath(form), config };
|
|
253
|
+
}
|
|
254
|
+
function normalizePrefix(prefix) {
|
|
255
|
+
const p = (prefix ?? DEFAULT_INBOX_PREFIX).trim().replace(/^\/+/, "");
|
|
256
|
+
return p ? p.endsWith("/") ? p : `${p}/` : "";
|
|
257
|
+
}
|
|
258
|
+
function failedPrefix(prefix) {
|
|
259
|
+
return `${prefix.replace(/\/+$/, "") || "inbox"}-failed/`;
|
|
260
|
+
}
|
|
261
|
+
function parseSubmission(id, raw) {
|
|
262
|
+
let value = raw;
|
|
263
|
+
if (typeof raw === "string") {
|
|
264
|
+
try {
|
|
265
|
+
value = JSON.parse(raw);
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (!value || typeof value !== "object") return null;
|
|
271
|
+
const v = value;
|
|
272
|
+
if (typeof v.formId !== "string" || !v.data || typeof v.data !== "object" || Array.isArray(v.data)) return null;
|
|
273
|
+
const createdAt = typeof v.createdAt === "number" ? v.createdAt : typeof v.createdAt === "string" ? /^\d+$/.test(v.createdAt) ? Number(v.createdAt) : Date.parse(v.createdAt) || 0 : 0;
|
|
274
|
+
return {
|
|
275
|
+
id,
|
|
276
|
+
formId: v.formId,
|
|
277
|
+
data: v.data,
|
|
278
|
+
createdAt,
|
|
279
|
+
...v.meta && typeof v.meta === "object" ? { meta: v.meta } : {}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function connectorInboxSource(connector, prefix = DEFAULT_INBOX_PREFIX, key = `connector:${prefix}`) {
|
|
283
|
+
const root = normalizePrefix(prefix);
|
|
284
|
+
return {
|
|
285
|
+
key,
|
|
286
|
+
async list() {
|
|
287
|
+
if (!connector.listPrefix) throw new Error("This storage can't list files: inbox ingestion needs a connector with listPrefix (S3, git, local folder).");
|
|
288
|
+
const paths = (await connector.listPrefix(root)).filter((p) => p.endsWith(".json") && p.startsWith(root));
|
|
289
|
+
const out = [];
|
|
290
|
+
for (const path of paths) {
|
|
291
|
+
let text;
|
|
292
|
+
try {
|
|
293
|
+
text = await connector.get(path);
|
|
294
|
+
} catch {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const sub = parseSubmission(path, text);
|
|
298
|
+
out.push(sub ?? { id: path, formId: "", data: {}, createdAt: 0, meta: { invalid: true } });
|
|
299
|
+
}
|
|
300
|
+
return out.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
|
|
301
|
+
},
|
|
302
|
+
async remove(id) {
|
|
303
|
+
await connector.delete(id);
|
|
304
|
+
},
|
|
305
|
+
async markFailed(id, error) {
|
|
306
|
+
let body = "";
|
|
307
|
+
try {
|
|
308
|
+
body = await connector.get(id);
|
|
309
|
+
} catch {
|
|
310
|
+
}
|
|
311
|
+
const target = failedPrefix(root) + id.slice(root.length);
|
|
312
|
+
let payload;
|
|
313
|
+
try {
|
|
314
|
+
payload = { ...JSON.parse(body), _error: error };
|
|
315
|
+
} catch {
|
|
316
|
+
payload = { raw: body, _error: error };
|
|
317
|
+
}
|
|
318
|
+
await connector.put({ path: target, content: JSON.stringify(payload, null, 2) });
|
|
319
|
+
await connector.delete(id);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function decodeFirestoreValue(v) {
|
|
324
|
+
if (!v || typeof v !== "object") return void 0;
|
|
325
|
+
if ("stringValue" in v) return v.stringValue;
|
|
326
|
+
if ("booleanValue" in v) return v.booleanValue;
|
|
327
|
+
if ("integerValue" in v) return Number(v.integerValue);
|
|
328
|
+
if ("doubleValue" in v) return Number(v.doubleValue);
|
|
329
|
+
if ("timestampValue" in v) return Date.parse(String(v.timestampValue));
|
|
330
|
+
if ("nullValue" in v) return null;
|
|
331
|
+
if ("arrayValue" in v) return (v.arrayValue?.values ?? []).map(decodeFirestoreValue);
|
|
332
|
+
if ("mapValue" in v) return decodeFirestoreFields(v.mapValue?.fields ?? {});
|
|
333
|
+
return void 0;
|
|
334
|
+
}
|
|
335
|
+
function decodeFirestoreFields(fields) {
|
|
336
|
+
return Object.fromEntries(Object.entries(fields).map(([k, v]) => [k, decodeFirestoreValue(v)]));
|
|
337
|
+
}
|
|
338
|
+
function firestoreInboxSource(opts) {
|
|
339
|
+
const collection = opts.collection || DEFAULT_FIREBASE_INBOX;
|
|
340
|
+
const base = `https://firestore.googleapis.com/v1/projects/${encodeURIComponent(opts.projectId)}/databases/(default)/documents`;
|
|
341
|
+
const doFetch = opts.fetch ?? ((url, init) => fetch(url, init));
|
|
342
|
+
async function call(url, init = {}) {
|
|
343
|
+
const token = await opts.getToken();
|
|
344
|
+
const headers = { "content-type": "application/json" };
|
|
345
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
346
|
+
const res = await doFetch(url, { ...init, headers });
|
|
347
|
+
if (!res.ok && res.status !== 404) {
|
|
348
|
+
let message = `Firestore answered HTTP ${res.status}`;
|
|
349
|
+
try {
|
|
350
|
+
const body = await res.json();
|
|
351
|
+
if (body?.error?.message) message += `: ${body.error.message}`;
|
|
352
|
+
} catch {
|
|
353
|
+
}
|
|
354
|
+
throw new Error(message);
|
|
355
|
+
}
|
|
356
|
+
return res;
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
key: `firestore:${opts.projectId}/${collection}`,
|
|
360
|
+
async list() {
|
|
361
|
+
const out = [];
|
|
362
|
+
let pageToken = "";
|
|
363
|
+
for (let guard = 0; guard < 50; guard++) {
|
|
364
|
+
const res = await call(`${base}/${encodeURIComponent(collection)}?pageSize=300${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`);
|
|
365
|
+
if (res.status === 404) break;
|
|
366
|
+
const body = await res.json();
|
|
367
|
+
for (const doc of body.documents ?? []) {
|
|
368
|
+
const fields = decodeFirestoreFields(doc.fields ?? {});
|
|
369
|
+
if (fields.status === "failed") continue;
|
|
370
|
+
const sub = parseSubmission(doc.name, fields);
|
|
371
|
+
out.push(sub ?? { id: doc.name, formId: "", data: {}, createdAt: 0, meta: { invalid: true } });
|
|
372
|
+
}
|
|
373
|
+
pageToken = body.nextPageToken ?? "";
|
|
374
|
+
if (!pageToken) break;
|
|
375
|
+
}
|
|
376
|
+
return out.sort((a, b) => a.createdAt - b.createdAt || a.id.localeCompare(b.id));
|
|
377
|
+
},
|
|
378
|
+
async remove(id) {
|
|
379
|
+
await call(`https://firestore.googleapis.com/v1/${id}`, { method: "DELETE" });
|
|
380
|
+
},
|
|
381
|
+
async markFailed(id, error) {
|
|
382
|
+
try {
|
|
383
|
+
await call(`https://firestore.googleapis.com/v1/${id}?updateMask.fieldPaths=status&updateMask.fieldPaths=error`, {
|
|
384
|
+
method: "PATCH",
|
|
385
|
+
body: JSON.stringify({ fields: { status: { stringValue: "failed" }, error: { stringValue: error.slice(0, 500) } } })
|
|
386
|
+
});
|
|
387
|
+
} catch {
|
|
388
|
+
await call(`https://firestore.googleapis.com/v1/${id}`, { method: "DELETE" });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
let resolver = null;
|
|
394
|
+
function setInboxSourceResolver(fn) {
|
|
395
|
+
resolver = fn;
|
|
396
|
+
}
|
|
397
|
+
async function resolveInboxSource(ctx, form) {
|
|
398
|
+
if (form.inbox.kind === "webhook") return null;
|
|
399
|
+
return resolver ? await resolver(ctx, form) : null;
|
|
400
|
+
}
|
|
401
|
+
function memoryInboxSource(submissions, key = "memory") {
|
|
402
|
+
const items = [...submissions];
|
|
403
|
+
const failed = [];
|
|
404
|
+
return {
|
|
405
|
+
key,
|
|
406
|
+
items,
|
|
407
|
+
failed,
|
|
408
|
+
async list() {
|
|
409
|
+
return [...items];
|
|
410
|
+
},
|
|
411
|
+
async remove(id) {
|
|
412
|
+
const i = items.findIndex((s) => s.id === id);
|
|
413
|
+
if (i >= 0) items.splice(i, 1);
|
|
414
|
+
},
|
|
415
|
+
async markFailed(id, error) {
|
|
416
|
+
failed.push({ id, error });
|
|
417
|
+
const i = items.findIndex((s) => s.id === id);
|
|
418
|
+
if (i >= 0) items.splice(i, 1);
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
function listForms(ctx) {
|
|
423
|
+
return ctx.store.list("forms");
|
|
424
|
+
}
|
|
425
|
+
function getForm(ctx, id) {
|
|
426
|
+
const f = ctx.store.get("forms", id);
|
|
427
|
+
if (!f) throw new OpsError("notFound", `Form "${id}" not found.${formsOf(ctx.store).length ? ` Forms: ${formsOf(ctx.store).map((x) => x.id).join(", ")}.` : ""}`);
|
|
428
|
+
return f;
|
|
429
|
+
}
|
|
430
|
+
function validateForm(ctx, input) {
|
|
431
|
+
const errors = [];
|
|
432
|
+
const name = (input.name ?? "").trim();
|
|
433
|
+
if (!name) errors.push("A name is required.");
|
|
434
|
+
const object = input.object ? objectOf(ctx.store, input.object) : void 0;
|
|
435
|
+
if (!object) errors.push(`Unknown object "${input.object ?? ""}".`);
|
|
436
|
+
const fields = [];
|
|
437
|
+
const seen = /* @__PURE__ */ new Set();
|
|
438
|
+
for (const f of input.fields ?? []) {
|
|
439
|
+
const def = object?.fields.find((x) => x.id === f.field);
|
|
440
|
+
if (object && !def) {
|
|
441
|
+
errors.push(`Field "${f.field}" doesn't exist on ${object.labelPlural}.`);
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (def && !isFormFieldType(def.type)) {
|
|
445
|
+
errors.push(`${def.label}: ${def.type} fields can't be collected by a public form.`);
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (seen.has(f.field)) continue;
|
|
449
|
+
seen.add(f.field);
|
|
450
|
+
fields.push({ field: f.field, label: (f.label ?? "").trim() || def?.label || f.field, required: !!f.required });
|
|
451
|
+
}
|
|
452
|
+
if (!fields.length) errors.push("Add at least one field.");
|
|
453
|
+
const kind = input.inbox?.kind;
|
|
454
|
+
const settings = Object.fromEntries(Object.entries(input.inbox?.settings ?? {}).map(([k, v]) => [k, String(v ?? "").trim()]));
|
|
455
|
+
if (!kind || !INBOX_KINDS.includes(kind)) errors.push(`Pick an inbox: ${INBOX_KINDS.join(", ")}.`);
|
|
456
|
+
if (kind === "s3" && !/^https?:\/\/\S+$/i.test(settings.uploadUrl ?? "")) errors.push("S3 inbox: the public upload URL (https://<bucket>…/inbox/) is required.");
|
|
457
|
+
if (kind === "firebase") {
|
|
458
|
+
if (!settings.projectId) errors.push("Firebase inbox: the project id is required.");
|
|
459
|
+
if (!settings.apiKey) errors.push("Firebase inbox: the web API key is required.");
|
|
460
|
+
}
|
|
461
|
+
if (kind === "webhook") {
|
|
462
|
+
if (!/^https:\/\/\S+$/i.test(settings.url ?? "")) errors.push("Webhook inbox: an https:// URL is required.");
|
|
463
|
+
if (settings.extra) {
|
|
464
|
+
try {
|
|
465
|
+
const parsed = JSON.parse(settings.extra);
|
|
466
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
|
467
|
+
} catch {
|
|
468
|
+
errors.push("Webhook inbox: extra fields must be a JSON object.");
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (input.ownerId && !userOf(ctx.store, input.ownerId)) errors.push(`Unknown owner "${input.ownerId}".`);
|
|
473
|
+
if (input.redirectUrl && !/^https?:\/\/\S+$/i.test(input.redirectUrl)) errors.push("The redirect URL must start with https://.");
|
|
474
|
+
if (input.publishedPath && (/^\/|\.\./.test(input.publishedPath) || !/\.html?$/.test(input.publishedPath))) errors.push('The page path must be relative and end with ".html" (e.g. forms/contact/index.html).');
|
|
475
|
+
const hiddenValues = {};
|
|
476
|
+
for (const [k, v] of Object.entries(input.hiddenValues ?? {})) {
|
|
477
|
+
if (v === void 0 || v === null || v === "") continue;
|
|
478
|
+
if (object && !object.fields.some((f) => f.id === k)) errors.push(`Hidden value: field "${k}" doesn't exist on ${object.labelPlural}.`);
|
|
479
|
+
hiddenValues[k] = v;
|
|
480
|
+
}
|
|
481
|
+
if (errors.length) throw new OpsError("validation", errors.join(" "), errors);
|
|
482
|
+
return {
|
|
483
|
+
...input.id ? { id: input.id } : {},
|
|
484
|
+
name,
|
|
485
|
+
object: input.object,
|
|
486
|
+
fields,
|
|
487
|
+
inbox: { kind, settings },
|
|
488
|
+
successMessage: (input.successMessage ?? "").trim() || "Thank you! We'll be in touch soon.",
|
|
489
|
+
...input.publishedPath ? { publishedPath: input.publishedPath } : {},
|
|
490
|
+
...input.texts ? { texts: input.texts } : {},
|
|
491
|
+
...Object.keys(hiddenValues).length ? { hiddenValues } : {},
|
|
492
|
+
...input.ownerId ? { ownerId: input.ownerId } : {},
|
|
493
|
+
...input.accentColor ? { accentColor: input.accentColor } : {},
|
|
494
|
+
...input.redirectUrl ? { redirectUrl: input.redirectUrl } : {}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
async function createForm(ctx, input) {
|
|
498
|
+
assertCan(ctx, "forms.write");
|
|
499
|
+
const valid = validateForm(ctx, input);
|
|
500
|
+
const now = ctx.now();
|
|
501
|
+
const id = input.id ?? newId(now);
|
|
502
|
+
if (ctx.store.get("forms", id)) throw new OpsError("conflict", `Form "${id}" already exists.`);
|
|
503
|
+
const doc = { ...valid, id, createdAt: now, updatedAt: now };
|
|
504
|
+
await ctx.store.write([{ op: "set", collection: "forms", doc: asDoc(doc) }], { message: `Create form ${valid.name}` });
|
|
505
|
+
return getForm(ctx, id);
|
|
506
|
+
}
|
|
507
|
+
async function updateForm(ctx, id, patch) {
|
|
508
|
+
assertCan(ctx, "forms.write");
|
|
509
|
+
const current = thaw(getForm(ctx, id));
|
|
510
|
+
const merged = { ...current, ...patch, id };
|
|
511
|
+
const valid = validateForm(ctx, merged);
|
|
512
|
+
const next = {
|
|
513
|
+
...valid,
|
|
514
|
+
id,
|
|
515
|
+
...current.publishedUrl ? { publishedUrl: current.publishedUrl } : {},
|
|
516
|
+
...current.publishedAt ? { publishedAt: current.publishedAt } : {},
|
|
517
|
+
...current.lastIngestAt ? { lastIngestAt: current.lastIngestAt } : {},
|
|
518
|
+
createdAt: current.createdAt,
|
|
519
|
+
updatedAt: ctx.now()
|
|
520
|
+
};
|
|
521
|
+
await ctx.store.write([{ op: "set", collection: "forms", doc: asDoc(next) }], { message: `Update form ${valid.name}` });
|
|
522
|
+
return getForm(ctx, id);
|
|
523
|
+
}
|
|
524
|
+
async function deleteForm(ctx, id) {
|
|
525
|
+
assertCan(ctx, "forms.write");
|
|
526
|
+
const form = getForm(ctx, id);
|
|
527
|
+
await ctx.store.write([{ op: "delete", collection: "forms", id }], { message: "Delete form" });
|
|
528
|
+
return { id, ...form.publishedAt ? { publishedPath: form.publishedPath || defaultFormPath(form) } : {} };
|
|
529
|
+
}
|
|
530
|
+
function suggestFormFields(ctx, objectId) {
|
|
531
|
+
const object = requireObject(ctx.store, objectId);
|
|
532
|
+
const wanted = objectId === "people" ? ["firstName", "lastName", "email", "phone", "jobTitle"] : objectId === "companies" ? ["name", "domain", "phone"] : [object.labelField];
|
|
533
|
+
return wanted.map((id) => object.fields.find((f) => f.id === id)).filter((f) => !!f && isFormFieldType(f.type)).map((f) => ({ field: f.id, label: f.label, required: f.id === "email" || f.id === object.labelField }));
|
|
534
|
+
}
|
|
535
|
+
async function buildFormHtml(ctx, formId, opts = {}) {
|
|
536
|
+
const form = getForm(ctx, formId);
|
|
537
|
+
const object = requireObject(ctx.store, form.object);
|
|
538
|
+
const built = buildFormArtifacts(form, object, { lang: opts.lang });
|
|
539
|
+
const url = opts.baseUrl ? `${opts.baseUrl.replace(/\/+$/, "")}/${built.path.replace(/index\.html$/, "")}` : form.publishedUrl;
|
|
540
|
+
return { html: built.html, embedSnippet: built.embedSnippet, path: built.path, ...url ? { url } : {} };
|
|
541
|
+
}
|
|
542
|
+
async function publishForm(ctx, formId, uploader, opts = {}) {
|
|
543
|
+
assertCan(ctx, "forms.write");
|
|
544
|
+
const form = getForm(ctx, formId);
|
|
545
|
+
const built = await buildFormHtml(ctx, formId, { lang: opts.lang });
|
|
546
|
+
await uploader.put({ path: built.path, content: built.html });
|
|
547
|
+
let url;
|
|
548
|
+
try {
|
|
549
|
+
url = uploader.publicUrl ? await uploader.publicUrl(built.path) : void 0;
|
|
550
|
+
} catch {
|
|
551
|
+
url = void 0;
|
|
552
|
+
}
|
|
553
|
+
if (url) url = url.replace(/index\.html$/, "");
|
|
554
|
+
await ctx.store.write(
|
|
555
|
+
[{ op: "patch", collection: "forms", id: form.id, fields: { publishedPath: built.path, publishedAt: ctx.now(), publishedUrl: url, updatedAt: ctx.now() } }],
|
|
556
|
+
{ message: `Publish form ${form.name}` }
|
|
557
|
+
);
|
|
558
|
+
return { path: built.path, ...url ? { url } : {}, embedSnippet: built.embedSnippet };
|
|
559
|
+
}
|
|
560
|
+
const FREE_MAIL_DOMAINS = /* @__PURE__ */ new Set([
|
|
561
|
+
"gmail.com",
|
|
562
|
+
"googlemail.com",
|
|
563
|
+
"yahoo.com",
|
|
564
|
+
"yahoo.fr",
|
|
565
|
+
"ymail.com",
|
|
566
|
+
"hotmail.com",
|
|
567
|
+
"hotmail.fr",
|
|
568
|
+
"outlook.com",
|
|
569
|
+
"outlook.fr",
|
|
570
|
+
"live.com",
|
|
571
|
+
"live.fr",
|
|
572
|
+
"msn.com",
|
|
573
|
+
"icloud.com",
|
|
574
|
+
"me.com",
|
|
575
|
+
"mac.com",
|
|
576
|
+
"aol.com",
|
|
577
|
+
"gmx.com",
|
|
578
|
+
"gmx.de",
|
|
579
|
+
"gmx.fr",
|
|
580
|
+
"web.de",
|
|
581
|
+
"proton.me",
|
|
582
|
+
"protonmail.com",
|
|
583
|
+
"pm.me",
|
|
584
|
+
"zoho.com",
|
|
585
|
+
"mail.com",
|
|
586
|
+
"yandex.com",
|
|
587
|
+
"yandex.ru",
|
|
588
|
+
"orange.fr",
|
|
589
|
+
"wanadoo.fr",
|
|
590
|
+
"free.fr",
|
|
591
|
+
"laposte.net",
|
|
592
|
+
"sfr.fr",
|
|
593
|
+
"bbox.fr",
|
|
594
|
+
"qq.com",
|
|
595
|
+
"163.com",
|
|
596
|
+
"fastmail.com",
|
|
597
|
+
"hey.com",
|
|
598
|
+
"tutanota.com"
|
|
599
|
+
]);
|
|
600
|
+
function companyDomainOfEmail(email) {
|
|
601
|
+
const domain = normalizeEmail(email).split("@")[1];
|
|
602
|
+
if (!domain || !domain.includes(".") || FREE_MAIL_DOMAINS.has(domain)) return null;
|
|
603
|
+
return domain;
|
|
604
|
+
}
|
|
605
|
+
const SECOND_LEVEL = /* @__PURE__ */ new Set(["co", "com", "net", "org", "gov", "ac", "edu", "ltd", "plc", "gouv", "asso"]);
|
|
606
|
+
function companyNameFromDomain(domain) {
|
|
607
|
+
const parts = domain.split(".");
|
|
608
|
+
parts.pop();
|
|
609
|
+
if (parts.length > 1 && SECOND_LEVEL.has(parts[parts.length - 1])) parts.pop();
|
|
610
|
+
const main = parts.pop() ?? domain;
|
|
611
|
+
return main.split(/[-_]/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
|
612
|
+
}
|
|
613
|
+
function emailFieldOf(form, object) {
|
|
614
|
+
return form.fields.map((f) => object.fields.find((x) => x.id === f.field)).find((d) => d?.type === "email")?.id ?? (object.fields.some((f) => f.id === "email" && f.type === "email") ? "email" : void 0);
|
|
615
|
+
}
|
|
616
|
+
function isSpam(sub) {
|
|
617
|
+
const hp = sub.data[HONEYPOT_NAME] ?? sub.meta?.hp;
|
|
618
|
+
return typeof hp === "string" ? hp.trim() !== "" : !!hp;
|
|
619
|
+
}
|
|
620
|
+
function alreadyIngested(ctx, submissionId) {
|
|
621
|
+
const a = activitiesOf(ctx.store).find((x) => x.meta?.source === "form" && x.meta?.submissionId === submissionId);
|
|
622
|
+
if (!a) return void 0;
|
|
623
|
+
return recordsOf(ctx.store).find((r) => a.targets.some((t) => t.id === r.id)) ?? null;
|
|
624
|
+
}
|
|
625
|
+
async function ingestOne(ctx, form, object, sub, dryRun) {
|
|
626
|
+
const done = alreadyIngested(ctx, sub.id);
|
|
627
|
+
if (done) return { outcome: "duplicate", record: done, companyCreated: false };
|
|
628
|
+
const values = {};
|
|
629
|
+
const missing = [];
|
|
630
|
+
for (const f of form.fields) {
|
|
631
|
+
const def = object.fields.find((x) => x.id === f.field);
|
|
632
|
+
if (!def) continue;
|
|
633
|
+
const raw = sub.data[f.field];
|
|
634
|
+
const empty = raw === void 0 || raw === null || typeof raw === "string" && !raw.trim() || Array.isArray(raw) && !raw.length || def.type === "boolean" && raw === false && f.required;
|
|
635
|
+
if (empty) {
|
|
636
|
+
if (f.required) missing.push(f.label || def.label);
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
values[f.field] = typeof raw === "string" ? raw.trim().slice(0, 5e3) : raw;
|
|
640
|
+
}
|
|
641
|
+
if (missing.length) throw new OpsError("validation", `Missing required field(s): ${missing.join(", ")}.`);
|
|
642
|
+
const emailField = emailFieldOf(form, object);
|
|
643
|
+
const email = emailField ? normalizeEmail(values[emailField]) : "";
|
|
644
|
+
if (emailField && values[emailField] !== void 0) values[emailField] = email;
|
|
645
|
+
const existing = email ? recordsOf(ctx.store, object.id).find((r) => normalizeEmail(r.fields[emailField]) === email) : void 0;
|
|
646
|
+
let companyId;
|
|
647
|
+
let companyCreated = false;
|
|
648
|
+
const companyField = object.id === "people" ? object.fields.find((f) => f.id === "company" && f.type === "relation" && f.relation?.object === "companies") : void 0;
|
|
649
|
+
const domain = email ? companyDomainOfEmail(email) : null;
|
|
650
|
+
if (companyField && domain && objectOf(ctx.store, "companies") && isEmptyFieldValue(companyField, existing?.fields.company) && values.company === void 0) {
|
|
651
|
+
const company = recordsOf(ctx.store, "companies").find((c) => normalizeDomain(c.fields.domain) === domain);
|
|
652
|
+
if (company) companyId = company.id;
|
|
653
|
+
else if (!dryRun) {
|
|
654
|
+
const created = await createRecord(ctx, "companies", { name: companyNameFromDomain(domain), domain }, { ownerId: form.ownerId ?? null, message: `Company from form ${form.name}` });
|
|
655
|
+
companyId = created.id;
|
|
656
|
+
companyCreated = true;
|
|
657
|
+
} else companyCreated = true;
|
|
658
|
+
if (companyId) values.company = companyId;
|
|
659
|
+
}
|
|
660
|
+
if (dryRun) {
|
|
661
|
+
const preview = existing ?? { id: "(new)", object: object.id, fields: { ...form.hiddenValues, ...values }, createdAt: ctx.now(), updatedAt: ctx.now() };
|
|
662
|
+
return { outcome: existing ? "updated" : "created", record: preview, companyCreated };
|
|
663
|
+
}
|
|
664
|
+
let record;
|
|
665
|
+
let outcome;
|
|
666
|
+
if (existing) {
|
|
667
|
+
record = await updateRecord(ctx, existing.id, values, { unknownFields: "drop", message: `Form submission ${form.name}` });
|
|
668
|
+
outcome = "updated";
|
|
669
|
+
} else {
|
|
670
|
+
record = await createRecord(ctx, object.id, { ...form.hiddenValues ?? {}, ...values }, { unknownFields: "drop", ownerId: form.ownerId ?? null, message: `Form submission ${form.name}` });
|
|
671
|
+
outcome = "created";
|
|
672
|
+
}
|
|
673
|
+
const qctx = queryContext(ctx, object);
|
|
674
|
+
const lines = form.fields.filter((f) => values[f.field] !== void 0 && f.field !== "company").map((f) => {
|
|
675
|
+
const def = object.fields.find((x) => x.id === f.field);
|
|
676
|
+
return `- **${f.label || def.label}:** ${formatFieldValue(def, record.fields[f.field] ?? values[f.field], qctx) || String(values[f.field])}`;
|
|
677
|
+
});
|
|
678
|
+
const page = typeof sub.meta?.page === "string" ? sub.meta.page : void 0;
|
|
679
|
+
if (page) lines.push(`- **Page:** ${page}`);
|
|
680
|
+
const targets = [{ object: record.object, id: record.id }, ...companyId ? [{ object: "companies", id: companyId }] : []];
|
|
681
|
+
await addActivity(ctx, {
|
|
682
|
+
type: "note",
|
|
683
|
+
title: `Form submission: ${form.name}`,
|
|
684
|
+
body: lines.join("\n"),
|
|
685
|
+
targets,
|
|
686
|
+
meta: { source: "form", formId: form.id, submissionId: sub.id, submittedAt: sub.createdAt || void 0, ...page ? { page } : {} }
|
|
687
|
+
});
|
|
688
|
+
ctx.log?.(`form ${form.id}: ${outcome} ${recordLabel(object, record)}`);
|
|
689
|
+
return { outcome, record, companyCreated };
|
|
690
|
+
}
|
|
691
|
+
function emptyReport() {
|
|
692
|
+
return { processed: 0, created: 0, updated: 0, failed: 0, errors: [], spam: 0, companiesCreated: 0, skippedForms: [], results: [] };
|
|
693
|
+
}
|
|
694
|
+
async function ingestSubmissions(ctx, submissions, opts = {}) {
|
|
695
|
+
assertCan(ctx, "records.write");
|
|
696
|
+
const report = emptyReport();
|
|
697
|
+
for (const sub of submissions) {
|
|
698
|
+
if (opts.formId && sub.formId && sub.formId !== opts.formId) continue;
|
|
699
|
+
const ack = async (kind, message) => {
|
|
700
|
+
if (opts.dryRun || !opts.source) return;
|
|
701
|
+
try {
|
|
702
|
+
if (kind === "remove") await opts.source.remove(sub.id);
|
|
703
|
+
else await opts.source.markFailed(sub.id, message ?? "failed");
|
|
704
|
+
} catch (err) {
|
|
705
|
+
report.errors.push({ submission: sub.id, message: `Couldn't acknowledge: ${err.message}` });
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
report.processed++;
|
|
709
|
+
if (isSpam(sub)) {
|
|
710
|
+
report.spam++;
|
|
711
|
+
report.results.push({ submission: sub.id, formId: sub.formId, outcome: "spam" });
|
|
712
|
+
await ack("remove");
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
const form = sub.formId ? ctx.store.get("forms", sub.formId) : void 0;
|
|
716
|
+
const object = form ? objectOf(ctx.store, form.object) : void 0;
|
|
717
|
+
try {
|
|
718
|
+
if (sub.meta?.invalid) throw new OpsError("validation", "Not a valid submission file.");
|
|
719
|
+
if (!form) throw new OpsError("notFound", `Unknown form "${sub.formId}".`);
|
|
720
|
+
if (!object) throw new OpsError("notFound", `Form "${form.name}" targets a missing object (${form.object}).`);
|
|
721
|
+
const res = await ingestOne(ctx, form, object, sub, !!opts.dryRun);
|
|
722
|
+
if (res.outcome === "created") report.created++;
|
|
723
|
+
if (res.outcome === "updated") report.updated++;
|
|
724
|
+
if (res.companyCreated) report.companiesCreated++;
|
|
725
|
+
report.results.push({ submission: sub.id, formId: sub.formId, outcome: res.outcome, recordId: res.record.id });
|
|
726
|
+
await ack("remove");
|
|
727
|
+
} catch (err) {
|
|
728
|
+
const message = err.message ?? String(err);
|
|
729
|
+
report.failed++;
|
|
730
|
+
report.errors.push({ submission: sub.id, message });
|
|
731
|
+
report.results.push({ submission: sub.id, formId: sub.formId, outcome: "failed", message });
|
|
732
|
+
await ack("fail", message);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return report;
|
|
736
|
+
}
|
|
737
|
+
async function listInbox(ctx, formId, opts = {}) {
|
|
738
|
+
assertCan(ctx, "records.read");
|
|
739
|
+
const forms = formId ? [getForm(ctx, formId)] : formsOf(ctx.store);
|
|
740
|
+
const skippedForms = [];
|
|
741
|
+
const sources = /* @__PURE__ */ new Map();
|
|
742
|
+
if (opts.source) sources.set(opts.source.key, opts.source);
|
|
743
|
+
else {
|
|
744
|
+
for (const form of forms) {
|
|
745
|
+
if (form.inbox.kind === "webhook") {
|
|
746
|
+
skippedForms.push({ formId: form.id, reason: "Webhook inbox: submissions stay in your webhook service." });
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
let source = null;
|
|
750
|
+
try {
|
|
751
|
+
source = await resolveInboxSource(ctx, form);
|
|
752
|
+
} catch (err) {
|
|
753
|
+
skippedForms.push({ formId: form.id, reason: err.message });
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
if (!source) skippedForms.push({ formId: form.id, reason: "No credentials to read this inbox here." });
|
|
757
|
+
else sources.set(source.key, source);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const wanted = new Set(forms.map((f) => f.id));
|
|
761
|
+
const submissions = [];
|
|
762
|
+
for (const source of sources.values()) {
|
|
763
|
+
for (const s of await source.list()) if (!formId || s.formId === formId || !s.formId && wanted.has(formId)) submissions.push(s);
|
|
764
|
+
}
|
|
765
|
+
return { submissions, skippedForms };
|
|
766
|
+
}
|
|
767
|
+
async function ingestInbox(ctx, formId, opts = {}) {
|
|
768
|
+
assertCan(ctx, "records.write");
|
|
769
|
+
const forms = formId ? [getForm(ctx, formId)] : formsOf(ctx.store);
|
|
770
|
+
const total = emptyReport();
|
|
771
|
+
const sources = /* @__PURE__ */ new Map();
|
|
772
|
+
if (opts.source) sources.set(opts.source.key, opts.source);
|
|
773
|
+
else {
|
|
774
|
+
for (const form of forms) {
|
|
775
|
+
if (form.inbox.kind === "webhook") {
|
|
776
|
+
total.skippedForms.push({ formId: form.id, reason: "Webhook inbox: submissions stay in your webhook service." });
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
let source = null;
|
|
780
|
+
try {
|
|
781
|
+
source = await resolveInboxSource(ctx, form);
|
|
782
|
+
} catch (err) {
|
|
783
|
+
total.skippedForms.push({ formId: form.id, reason: err.message });
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
if (!source) total.skippedForms.push({ formId: form.id, reason: "No credentials to read this inbox here." });
|
|
787
|
+
else sources.set(source.key, source);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
for (const source of sources.values()) {
|
|
791
|
+
let pending = await source.list();
|
|
792
|
+
if (formId) pending = pending.filter((s) => s.formId === formId);
|
|
793
|
+
if (opts.limit) pending = pending.slice(0, opts.limit);
|
|
794
|
+
const r = await ingestSubmissions(ctx, pending, { dryRun: opts.dryRun, source });
|
|
795
|
+
total.processed += r.processed;
|
|
796
|
+
total.created += r.created;
|
|
797
|
+
total.updated += r.updated;
|
|
798
|
+
total.failed += r.failed;
|
|
799
|
+
total.errors.push(...r.errors);
|
|
800
|
+
total.spam += r.spam ?? 0;
|
|
801
|
+
total.companiesCreated += r.companiesCreated ?? 0;
|
|
802
|
+
total.results.push(...r.results ?? []);
|
|
803
|
+
}
|
|
804
|
+
if (!opts.dryRun) {
|
|
805
|
+
const touched = new Set(total.results.map((r) => r.formId).filter(Boolean));
|
|
806
|
+
const now = ctx.now();
|
|
807
|
+
const mutations = forms.filter((f) => touched.has(f.id) || formId === f.id).map((f) => ({ op: "patch", collection: "forms", id: f.id, fields: { lastIngestAt: now } }));
|
|
808
|
+
try {
|
|
809
|
+
if (mutations.length) await ctx.store.write(mutations, { message: "Ingest lead inbox" });
|
|
810
|
+
} catch {
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return total;
|
|
814
|
+
}
|
|
815
|
+
export {
|
|
816
|
+
DEFAULT_FIREBASE_INBOX,
|
|
817
|
+
DEFAULT_FORM_TEXTS,
|
|
818
|
+
DEFAULT_INBOX_PREFIX,
|
|
819
|
+
FORM_FIELD_TYPES,
|
|
820
|
+
FREE_MAIL_DOMAINS,
|
|
821
|
+
HONEYPOT_NAME,
|
|
822
|
+
INBOX_KINDS,
|
|
823
|
+
MIN_FILL_MS,
|
|
824
|
+
buildFormArtifacts,
|
|
825
|
+
buildFormHtml,
|
|
826
|
+
companyDomainOfEmail,
|
|
827
|
+
connectorInboxSource,
|
|
828
|
+
createForm,
|
|
829
|
+
defaultFormPath,
|
|
830
|
+
deleteForm,
|
|
831
|
+
escapeHtml,
|
|
832
|
+
firestoreInboxSource,
|
|
833
|
+
getForm,
|
|
834
|
+
ingestInbox,
|
|
835
|
+
ingestSubmissions,
|
|
836
|
+
isFormFieldType,
|
|
837
|
+
listForms,
|
|
838
|
+
listInbox,
|
|
839
|
+
memoryInboxSource,
|
|
840
|
+
parseSubmission,
|
|
841
|
+
publishForm,
|
|
842
|
+
resolveInboxSource,
|
|
843
|
+
setInboxSourceResolver,
|
|
844
|
+
suggestFormFields,
|
|
845
|
+
updateForm,
|
|
846
|
+
validateForm
|
|
847
|
+
};
|