domma-cms 0.16.0 → 0.18.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/CLAUDE.md +2 -0
- package/admin/css/dashboard.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +4 -4
- package/admin/index.html +2 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +1 -1
- package/admin/js/lib/card-builder.js +3 -3
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +5 -5
- package/admin/js/templates/dashboard/activity-feed.html +3 -0
- package/admin/js/templates/dashboard/cache.html +32 -0
- package/admin/js/templates/dashboard/health-detail.html +2 -0
- package/admin/js/templates/dashboard/journeys.html +17 -0
- package/admin/js/templates/dashboard/kpi-strip.html +34 -0
- package/admin/js/templates/dashboard/spike-feed.html +3 -0
- package/admin/js/templates/dashboard/top-pages.html +3 -0
- package/admin/js/templates/dashboard/traffic-chart.html +3 -0
- package/admin/js/templates/dashboard.html +26 -44
- package/admin/js/templates/settings.html +26 -0
- package/admin/js/views/block-editor-enhance.js +1 -1
- package/admin/js/views/dashboard/lib/escape.js +1 -0
- package/admin/js/views/dashboard/widgets/activity-feed.js +1 -0
- package/admin/js/views/dashboard/widgets/cache.js +1 -0
- package/admin/js/views/dashboard/widgets/health-detail.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -0
- package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -0
- package/admin/js/views/dashboard/widgets/spike-feed.js +6 -0
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -0
- package/admin/js/views/dashboard/widgets/traffic-chart.js +1 -0
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/page-editor.js +42 -37
- package/admin/js/views/settings.js +3 -3
- package/config/cache.json +4 -0
- package/config/cache.json.example +12 -0
- package/config/plugins.json +3 -0
- package/package.json +2 -2
- package/plugins/analytics/daily.json +5 -0
- package/plugins/analytics/journeys.json +10 -0
- package/plugins/analytics/lifetime.json +25 -0
- package/plugins/analytics/plugin.js +231 -16
- package/plugins/analytics/public/inject-body.html +26 -2
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/config.js +12 -1
- package/server/routes/api/cache.js +57 -0
- package/server/routes/api/dashboard.js +239 -0
- package/server/routes/api/navigation.js +2 -0
- package/server/routes/api/settings.js +3 -0
- package/server/routes/public.js +11 -3
- package/server/server.js +18 -3
- package/server/services/blocks.js +3 -0
- package/server/services/cache/drivers/MemoryDriver.js +118 -0
- package/server/services/cache/drivers/NoneDriver.js +12 -0
- package/server/services/cache/index.js +229 -0
- package/server/services/cache/lru.js +61 -0
- package/server/services/collections.js +17 -4
- package/server/services/content.js +7 -2
- package/server/services/email.js +60 -20
- package/server/services/forms.js +3 -0
- package/server/services/health.js +282 -0
- package/server/services/markdown.js +25 -15
- package/server/services/plugins.js +37 -5
- package/server/services/views.js +4 -0
- package/server/templates/page.html +130 -130
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import{api as
|
|
2
|
-
`,
|
|
3
|
-
`,
|
|
1
|
+
import{api as C,apiRequest as v}from"../api.js";import{populateThemeSelect as w}from"../lib/themes.js";export const settingsView={templateUrl:"/admin/js/templates/settings.html",async onMount(e){E.tabs(e.find("#settings-tabs").get(0));const k=E.loader(e.get(0),{type:"dots"}),l=await C.settings.get().catch(()=>({}));if(k.destroy(),e.find("#field-site-title").val(l.title||""),e.find("#field-tagline").val(l.tagline||""),e.find("#field-font-family").val(l.fontFamily||"Roboto"),e.find("#field-font-size").val(l.fontSize||16),w(e.find("#field-theme").get(0)),w(e.find("#field-admin-theme").get(0)),e.find("#field-theme").val(l.baseTheme||l.theme||"charcoal-dark"),l.baseTheme){e.find("#field-theme").prop("disabled",!0);const t=document.createElement("p");t.className="form-hint",t.style.cssText="margin-top:.4rem;font-size:.8rem;color:var(--dm-info);";const s=document.createElement("a");s.href="#/plugins/theme-roller",s.textContent="Theme Roller",t.appendChild(document.createTextNode(`Custom theme \u201C${l.theme}\u201D is active (based on ${l.baseTheme}). Manage via `)),t.appendChild(s),t.appendChild(document.createTextNode(".")),e.find("#field-theme").get(0).closest(".col-6").appendChild(t)}e.find("#field-admin-theme").val(l.adminTheme||"charcoal-dark");const p=l.autoTheme||{},h=!!p.enabled,b=e.find("#field-auto-theme-enabled"),y=e.find("#auto-theme-fields"),u=e.find("#field-theme"),f=u.get(0).innerHTML;e.find("#field-day-theme").html(f),e.find("#field-night-theme").html(f),l.baseTheme?(b.prop("disabled",!0),e.find("#auto-theme-roller-hint").show()):(b.prop("checked",h),h&&(y.show(),u.prop("disabled",!0)),b.on("change",function(){const t=this.checked;y.toggle(t),u.prop("disabled",t)})),e.find("#field-day-theme").val(p.dayTheme||"charcoal-light"),e.find("#field-night-theme").val(p.nightTheme||"charcoal-dark"),e.find("#field-day-start").val(p.dayStart||"07:00"),e.find("#field-night-start").val(p.nightStart||"19:00"),e.find("#field-spacer-size").val(l.layoutOptions?.spacerSize??40),e.find("#field-seo-title").val(l.seo?.defaultTitle||""),e.find("#field-seo-separator").val(l.seo?.titleSeparator||" | "),e.find("#field-seo-desc").val(l.seo?.defaultDescription||""),e.find("#field-footer-copy").val(l.footer?.copyright||""),e.find("#field-social-twitter").val(l.social?.twitter||""),e.find("#field-social-facebook").val(l.social?.facebook||""),e.find("#field-social-instagram").val(l.social?.instagram||""),e.find("#field-social-linkedin").val(l.social?.linkedin||""),e.find("#field-social-github").val(l.social?.github||""),e.find("#field-social-youtube").val(l.social?.youtube||""),e.find("#field-smtp-host").val(l.smtp?.host||""),e.find("#field-smtp-port").val(l.smtp?.port||587),e.find("#field-smtp-user").val(l.smtp?.user||""),e.find("#field-smtp-pass").val(l.smtp?.pass||""),e.find("#field-smtp-secure").prop("checked",l.smtp?.secure||!1),e.find("#field-smtp-from-address").val(l.smtp?.fromAddress||""),e.find("#field-smtp-from-name").val(l.smtp?.fromName||"");const i=l.backToTop||{};e.find("#field-btt-enabled").prop("checked",i.enabled!==!1),e.find("#field-btt-threshold").val(i.scrollThreshold??300),e.find("#field-btt-position").val(i.position||"bottom-right"),e.find("#field-btt-offset").val(i.offset??32),e.find("#field-btt-bottom-offset").val(i.bottomOffset??i.offset??32),e.find("#field-btt-label").val(i.label||""),e.find("#field-btt-smooth").prop("checked",i.smooth!==!1);const d=l.cookieConsent||{};e.find("#field-cc-enabled").prop("checked",d.enabled!==!1),e.find("#field-cc-message").val(d.message||""),e.find("#field-cc-accept-all").val(d.acceptAllText||"Accept All"),e.find("#field-cc-reject-all").val(d.rejectAllText||"Reject All"),e.find("#field-cc-customize").val(d.customizeText||"Customize"),e.find("#field-cc-save-prefs").val(d.savePreferencesText||"Save Preferences"),e.find("#field-cc-privacy-text").val(d.privacyPolicyText||"Privacy Policy"),e.find("#field-cc-privacy-url").val(d.privacyPolicyUrl||""),e.find("#field-cc-cookie-text").val(d.cookiePolicyText||"Cookie Policy"),e.find("#field-cc-cookie-url").val(d.cookiePolicyUrl||""),e.find("#field-cc-position").val(d.position||"bottom"),e.find("#field-cc-layout").val(d.layout||"bar"),e.find("#field-cc-theme").val(d.theme||"dark"),e.find("#field-cc-show-functional").prop("checked",d.showFunctional!==!1),e.find("#field-cc-show-analytics").prop("checked",d.showAnalytics!==!1),e.find("#field-cc-show-marketing").prop("checked",d.showMarketing!==!1),e.find("#field-cc-version").val(d.consentVersion||"1.0");const m=l.breadcrumbs||{};e.find("#field-breadcrumbs-enabled").prop("checked",m.enabled===!0),e.find("#field-breadcrumbs-home-label").val(m.homeLabel||"Home"),e.find("#field-breadcrumbs-position").val(m.position||"TL"),e.find("#field-bc-offset-x").val(m.offsetX??8),e.find("#field-bc-offset-y").val(m.offsetY??8);function c(t){e.find(".bc-pos-btn").each(function(){const s=this.dataset.pos===t;this.style.background=s?"var(--primary, #5b8cff)":"",this.style.color=s?"#fff":""})}c(m.position||"TL"),e.find(".bc-pos-btn").on("click",function(){const t=this.dataset.pos;e.find("#field-breadcrumbs-position").val(t),c(t)});let a=null;try{const{css:t}=await v("/settings/custom-css");e.find("#field-custom-css").val(t||""),E.editor&&(a=E.editor(e.find("#field-custom-css").get(0),{mode:"code",language:"css",lineNumbers:!0,showToolbar:!1,minHeight:420,placeholder:"/* Add your custom CSS here */",characterCount:!0}))}catch{}a&&a._editorEl&&a._editorEl.addEventListener("keydown",t=>{if(!t.ctrlKey&&!t.metaKey)return;if(t.key==="s"){t.preventDefault(),e.find("#save-css-btn").get(0)?.click();return}const s=a._editorEl,n=s.selectionStart!==s.selectionEnd;if((t.key==="c"||t.key==="x")&&!n){t.preventDefault();const o=s.value,g=s.selectionStart,T=o.lastIndexOf(`
|
|
2
|
+
`,g-1)+1,x=o.indexOf(`
|
|
3
|
+
`,g),S=x===-1?o.slice(T):o.slice(T,x+1);if(navigator.clipboard.writeText(S),t.key==="x"){const z=o.slice(0,T),P=x===-1?"":o.slice(x+1);s.value=z+P,s.selectionStart=s.selectionEnd=T,s.dispatchEvent(new Event("input",{bubbles:!0})),E.toast("Line cut",{type:"info",duration:1500})}else E.toast("Line copied",{type:"info",duration:1500})}}),e.find("#save-settings-btn").on("click",async()=>{const t=e.find("#field-admin-theme").val(),s=!l.baseTheme&&e.find("#field-auto-theme-enabled").prop("checked"),n={enabled:s,dayTheme:e.find("#field-day-theme").val()||"charcoal-light",nightTheme:e.find("#field-night-theme").val()||"charcoal-dark",dayStart:e.find("#field-day-start").val()||"07:00",nightStart:e.find("#field-night-start").val()||"19:00"},o={title:e.find("#field-site-title").val().trim(),tagline:e.find("#field-tagline").val().trim(),fontFamily:e.find("#field-font-family").val()||"Roboto",fontSize:parseInt(e.find("#field-font-size").val(),10)||16,theme:l.baseTheme?l.theme:s?n.dayTheme:e.find("#field-theme").val(),...l.baseTheme?{baseTheme:l.baseTheme}:{},autoTheme:n,adminTheme:t,layoutOptions:{spacerSize:parseInt(e.find("#field-spacer-size").val(),10)||40},seo:{defaultTitle:e.find("#field-seo-title").val().trim(),titleSeparator:e.find("#field-seo-separator").val()||" | ",defaultDescription:e.find("#field-seo-desc").val().trim()},footer:{copyright:e.find("#field-footer-copy").val().trim(),links:l.footer?.links||[]},social:{twitter:e.find("#field-social-twitter").val().trim(),facebook:e.find("#field-social-facebook").val().trim(),instagram:e.find("#field-social-instagram").val().trim(),linkedin:e.find("#field-social-linkedin").val().trim(),github:e.find("#field-social-github").val().trim(),youtube:e.find("#field-social-youtube").val().trim()},smtp:{host:e.find("#field-smtp-host").val().trim(),port:parseInt(e.find("#field-smtp-port").val(),10)||587,user:e.find("#field-smtp-user").val().trim(),pass:e.find("#field-smtp-pass").val(),secure:e.find("#field-smtp-secure").prop("checked"),fromAddress:e.find("#field-smtp-from-address").val().trim(),fromName:e.find("#field-smtp-from-name").val().trim()},backToTop:{enabled:e.find("#field-btt-enabled").prop("checked"),scrollThreshold:parseInt(e.find("#field-btt-threshold").val(),10)||300,position:e.find("#field-btt-position").val()||"bottom-right",offset:parseInt(e.find("#field-btt-offset").val(),10)||32,bottomOffset:parseInt(e.find("#field-btt-bottom-offset").val(),10)||32,label:e.find("#field-btt-label").val().trim(),smooth:e.find("#field-btt-smooth").prop("checked")},cookieConsent:{enabled:e.find("#field-cc-enabled").prop("checked"),message:e.find("#field-cc-message").val().trim(),acceptAllText:e.find("#field-cc-accept-all").val().trim(),rejectAllText:e.find("#field-cc-reject-all").val().trim(),customizeText:e.find("#field-cc-customize").val().trim(),savePreferencesText:e.find("#field-cc-save-prefs").val().trim(),privacyPolicyText:e.find("#field-cc-privacy-text").val().trim(),privacyPolicyUrl:e.find("#field-cc-privacy-url").val().trim(),cookiePolicyText:e.find("#field-cc-cookie-text").val().trim(),cookiePolicyUrl:e.find("#field-cc-cookie-url").val().trim(),position:e.find("#field-cc-position").val(),layout:e.find("#field-cc-layout").val(),theme:e.find("#field-cc-theme").val(),showFunctional:e.find("#field-cc-show-functional").prop("checked"),showAnalytics:e.find("#field-cc-show-analytics").prop("checked"),showMarketing:e.find("#field-cc-show-marketing").prop("checked"),consentVersion:e.find("#field-cc-version").val().trim()||"1.0"}},g=e.find("#save-settings-btn");g.prop("disabled",!0);try{await C.settings.save(o),Domma.theme.set(t),E.toast("Settings saved.",{type:"success"})}catch{E.toast("Failed to save settings.",{type:"error"})}finally{g.prop("disabled",!1)}}),e.find("#send-test-email-btn").on("click",async()=>{const t=e.find("#field-test-email-to").val().trim(),s=e.find("#test-email-result").get(0),n=e.find("#send-test-email-btn");n.prop("disabled",!0),s&&(s.textContent="Sending\u2026",s.style.color="");try{const o=await v("/settings/test-email",{method:"POST",body:JSON.stringify({to:t||void 0})});s&&(s.textContent=o.message||"Test email sent.",s.style.color="var(--success,#4ade80)")}catch(o){s&&(s.textContent=o.message||"Failed to send test email.",s.style.color="var(--danger,#f87171)")}finally{n.prop("disabled",!1)}}),e.find("#save-breadcrumbs-btn").on("click",async()=>{const t=e.find("#save-breadcrumbs-btn");t.prop("disabled",!0);try{const s={enabled:e.find("#field-breadcrumbs-enabled").prop("checked"),homeLabel:e.find("#field-breadcrumbs-home-label").val().trim()||"Home",position:e.find("#field-breadcrumbs-position").val()||"TL",offsetX:parseInt(e.find("#field-bc-offset-x").val(),10)||8,offsetY:parseInt(e.find("#field-bc-offset-y").val(),10)||8};await C.settings.save({...l,breadcrumbs:s}),l.breadcrumbs=s,E.toast("Breadcrumbs settings saved.",{type:"success"})}catch{E.toast("Failed to save breadcrumbs settings.",{type:"error"})}finally{t.prop("disabled",!1)}}),e.find("#save-css-btn").on("click",async()=>{const t=a?a.getValue():e.find("#field-custom-css").val(),s=e.find("#save-css-btn");s.prop("disabled",!0);try{await v("/settings/custom-css",{method:"PUT",body:JSON.stringify({css:t})}),E.toast("Custom CSS saved.",{type:"success"})}catch(n){E.toast(n.message||"Failed to save CSS.",{type:"error"})}finally{s.prop("disabled",!1)}});async function r(){try{const t=await v("/cache/status"),s=t.enabled?`enabled (${t.driver})`:"disabled",n=t.maxItems?` \xB7 ${t.size} / ${t.maxItems} entries`:"";e.find("#cache-status").text(s+n)}catch{e.find("#cache-status").text("unknown")}try{const{entries:t}=await v("/cache/keys");A(e.find("#cache-keys-table"),t),e.find("#cache-key-count").text(`(${t.length})`)}catch(t){e.find("#cache-keys-table").get(0).textContent="Failed to load keys: "+t.message}}await r(),e.find("#btn-clear-cache").on("click",async()=>{if(!await E.confirm("Clear the entire response cache?"))return;const t=e.find("#btn-clear-cache");t.prop("disabled",!0);try{await v("/cache/clear",{method:"POST"}),E.toast("Cache cleared.",{type:"success"}),await r()}catch(s){E.toast(s.message||"Failed to clear cache.",{type:"error"})}finally{t.prop("disabled",!1)}}),e.find("#btn-refresh-cache-keys").on("click",r)}};function A(e,k){const l=e.get(0);if(!l)return;for(;l.firstChild;)l.removeChild(l.firstChild);if(!k.length){const f=document.createElement("p");f.className="text-muted",f.style.fontSize=".875rem",f.textContent="Cache is empty.",l.appendChild(f);return}const p=[...k].reverse(),h=document.createElement("table");h.className="table table-compact";const b=document.createElement("thead"),y=document.createElement("tr");for(const f of["Key","Tags","Expires"]){const i=document.createElement("th");i.textContent=f,y.appendChild(i)}b.appendChild(y),h.appendChild(b);const u=document.createElement("tbody");for(const f of p){const i=document.createElement("tr"),d=document.createElement("td");d.style.fontFamily="monospace",d.style.fontSize="12px",d.textContent=f.key,i.appendChild(d);const m=document.createElement("td");m.style.fontSize="12px";for(const a of f.tags){const r=document.createElement("span");r.className="badge",r.style.marginRight="4px",r.style.fontSize="11px",r.textContent=a,m.appendChild(r)}i.appendChild(m);const c=document.createElement("td");if(c.style.fontSize="12px",c.style.color="var(--dm-muted, #6b7280)",f.expiresAt===null)c.textContent="no expiry";else{const a=f.expiresAt-Date.now();a<=0?c.textContent="expired":a<6e4?c.textContent=`${Math.round(a/1e3)}s`:a<36e5?c.textContent=`${Math.round(a/6e4)}m`:c.textContent=`${Math.round(a/36e5)}h`}i.appendChild(c),u.appendChild(i)}h.appendChild(u),l.appendChild(h)}
|
package/config/plugins.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "domma-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server/server.js",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"@fastify/rate-limit": "^10.3.0",
|
|
74
74
|
"@fastify/static": "9.1.1",
|
|
75
75
|
"bcryptjs": "^3.0.3",
|
|
76
|
-
"domma-js": "^0.
|
|
76
|
+
"domma-js": "^0.25.1",
|
|
77
77
|
"dotenv": "^17.2.3",
|
|
78
78
|
"fastify": "5.8.5",
|
|
79
79
|
"gray-matter": "^4.0.3",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"/": 162,
|
|
3
|
+
"/about": 88,
|
|
4
|
+
"/blog": 42,
|
|
5
|
+
"/contact": 31,
|
|
6
|
+
"/resources/typography": 4,
|
|
7
|
+
"/resources": 13,
|
|
8
|
+
"/resources/shortcodes": 14,
|
|
9
|
+
"/resources/cards": 19,
|
|
10
|
+
"/resources/interactive": 13,
|
|
11
|
+
"/resources/grid": 6,
|
|
12
|
+
"/forms": 14,
|
|
13
|
+
"/resources/effects": 6,
|
|
14
|
+
"/blog/hello-world": 28,
|
|
15
|
+
"/feedback": 42,
|
|
16
|
+
"/resources/dependencies": 2,
|
|
17
|
+
"/resources/components": 6,
|
|
18
|
+
"/gdpr": 3,
|
|
19
|
+
"/scratch": 84,
|
|
20
|
+
"/getting-started": 3,
|
|
21
|
+
"/resources/pro": 1,
|
|
22
|
+
"/todo": 23,
|
|
23
|
+
"/thank-you": 1,
|
|
24
|
+
"/test-from-controller": 1
|
|
25
|
+
}
|
|
@@ -22,6 +22,9 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
22
22
|
const LIFETIME_FILE = path.join(__dirname, 'lifetime.json');
|
|
23
23
|
const DAILY_FILE = path.join(__dirname, 'daily.json');
|
|
24
24
|
const LEGACY_FILE = path.join(__dirname, 'stats.json');
|
|
25
|
+
const JOURNEYS_FILE = path.join(__dirname, 'journeys.json');
|
|
26
|
+
const JOURNEY_DAILY_CAP = 5000;
|
|
27
|
+
const REALTIME_WINDOW_MS = 5 * 60 * 1000;
|
|
25
28
|
|
|
26
29
|
async function readJson(file, fallback) {
|
|
27
30
|
try {
|
|
@@ -40,23 +43,30 @@ async function writeJson(file, data) {
|
|
|
40
43
|
/**
|
|
41
44
|
* One-shot migration from the legacy flat stats.json.
|
|
42
45
|
* Preserves existing totals as lifetime numbers; daily history starts empty.
|
|
46
|
+
* Also seeds journeys.json if missing.
|
|
43
47
|
*/
|
|
44
48
|
async function migrateLegacy() {
|
|
49
|
+
let lifetimeExists = false;
|
|
45
50
|
try {
|
|
46
51
|
await fs.access(LIFETIME_FILE);
|
|
47
|
-
|
|
48
|
-
} catch {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
await writeJson(LIFETIME_FILE, {});
|
|
52
|
+
lifetimeExists = true;
|
|
53
|
+
} catch { /* not migrated */ }
|
|
54
|
+
|
|
55
|
+
if (!lifetimeExists) {
|
|
56
|
+
const legacy = await readJson(LEGACY_FILE, null);
|
|
57
|
+
if (legacy && typeof legacy === 'object') {
|
|
58
|
+
await writeJson(LIFETIME_FILE, legacy);
|
|
59
|
+
} else {
|
|
60
|
+
await writeJson(LIFETIME_FILE, {});
|
|
61
|
+
}
|
|
58
62
|
await writeJson(DAILY_FILE, {});
|
|
59
63
|
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await fs.access(JOURNEYS_FILE);
|
|
67
|
+
} catch {
|
|
68
|
+
await writeJson(JOURNEYS_FILE, {});
|
|
69
|
+
}
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
function todayKey(date = new Date()) {
|
|
@@ -131,14 +141,164 @@ function escapeCsvCell(value) {
|
|
|
131
141
|
return str;
|
|
132
142
|
}
|
|
133
143
|
|
|
144
|
+
function eventsInRange(journeys, start, end) {
|
|
145
|
+
const startMs = start.getTime();
|
|
146
|
+
const endMs = end.getTime();
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const [day, events] of Object.entries(journeys)) {
|
|
149
|
+
const dayMs = new Date(day + 'T00:00:00Z').getTime();
|
|
150
|
+
if (dayMs < startMs || dayMs > endMs) continue;
|
|
151
|
+
for (const ev of events) out.push(ev);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function groupBySession(events) {
|
|
157
|
+
const sessions = new Map();
|
|
158
|
+
for (const ev of events) {
|
|
159
|
+
if (!sessions.has(ev.sid)) sessions.set(ev.sid, []);
|
|
160
|
+
sessions.get(ev.sid).push(ev);
|
|
161
|
+
}
|
|
162
|
+
for (const arr of sessions.values()) arr.sort((a, b) => a.t - b.t);
|
|
163
|
+
return sessions;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function aggregateJourneys(events) {
|
|
167
|
+
const sessions = groupBySession(events);
|
|
168
|
+
const entry = new Map();
|
|
169
|
+
const exit = new Map();
|
|
170
|
+
const paths = new Map(); // key: '\x00'-joined, value: { from, to, count }
|
|
171
|
+
let bouncedSessions = 0;
|
|
172
|
+
|
|
173
|
+
for (const arr of sessions.values()) {
|
|
174
|
+
if (arr.length === 0) continue;
|
|
175
|
+
entry.set(arr[0].url, (entry.get(arr[0].url) || 0) + 1);
|
|
176
|
+
exit.set(arr[arr.length - 1].url, (exit.get(arr[arr.length - 1].url) || 0) + 1);
|
|
177
|
+
if (arr.length === 1) bouncedSessions += 1;
|
|
178
|
+
for (let i = 0; i < arr.length - 1; i += 1) {
|
|
179
|
+
const from = arr[i].url;
|
|
180
|
+
const to = arr[i + 1].url;
|
|
181
|
+
const key = from + '\x00' + to; // NUL is invalid in URL pathnames, so unambiguous
|
|
182
|
+
const existing = paths.get(key);
|
|
183
|
+
if (existing) {
|
|
184
|
+
existing.count += 1;
|
|
185
|
+
} else {
|
|
186
|
+
paths.set(key, { from, to, count: 1 });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const toSorted = (m) => Array.from(m.entries())
|
|
192
|
+
.map(([url, count]) => ({ url, count }))
|
|
193
|
+
.sort((a, b) => b.count - a.count)
|
|
194
|
+
.slice(0, 10);
|
|
195
|
+
|
|
196
|
+
const pathsSorted = Array.from(paths.values())
|
|
197
|
+
.sort((a, b) => b.count - a.count)
|
|
198
|
+
.slice(0, 10);
|
|
199
|
+
|
|
200
|
+
const totalSessions = sessions.size;
|
|
201
|
+
return {
|
|
202
|
+
entry: toSorted(entry),
|
|
203
|
+
exit: toSorted(exit),
|
|
204
|
+
paths: pathsSorted,
|
|
205
|
+
bounceRate: totalSessions === 0 ? 0 : +(bouncedSessions / totalSessions).toFixed(3),
|
|
206
|
+
totalSessions
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function detectSpikes(daily, now = new Date()) {
|
|
211
|
+
const todayStr = todayKey(now);
|
|
212
|
+
const currentHour = now.getUTCHours();
|
|
213
|
+
|
|
214
|
+
// Approximation: distribute today's hits evenly across elapsed hours.
|
|
215
|
+
const todayUrls = daily[todayStr] || {};
|
|
216
|
+
const elapsedHours = Math.max(1, currentHour + 1);
|
|
217
|
+
|
|
218
|
+
const flagged = [];
|
|
219
|
+
for (const [url, totalToday] of Object.entries(todayUrls)) {
|
|
220
|
+
const currentHits = totalToday / elapsedHours;
|
|
221
|
+
|
|
222
|
+
// Baseline: previous 7 days' same-hour share (assume 24-hour spread)
|
|
223
|
+
const samples = [];
|
|
224
|
+
for (let i = 1; i <= 7; i += 1) {
|
|
225
|
+
const prev = new Date(now);
|
|
226
|
+
prev.setUTCDate(prev.getUTCDate() - i);
|
|
227
|
+
const key = todayKey(prev);
|
|
228
|
+
const sample = (daily[key] && daily[key][url]) ? daily[key][url] / 24 : 0;
|
|
229
|
+
samples.push(sample);
|
|
230
|
+
}
|
|
231
|
+
const mean = samples.reduce((a, b) => a + b, 0) / samples.length;
|
|
232
|
+
const variance = samples.reduce((acc, x) => acc + (x - mean) * (x - mean), 0) / samples.length;
|
|
233
|
+
const stddev = Math.sqrt(variance);
|
|
234
|
+
|
|
235
|
+
if (currentHits >= 5 && currentHits > mean + 2 * stddev && mean > 0) {
|
|
236
|
+
flagged.push({
|
|
237
|
+
url,
|
|
238
|
+
hits: Math.round(currentHits),
|
|
239
|
+
baseline: Math.round(mean),
|
|
240
|
+
ratio: +(currentHits / Math.max(mean, 0.5)).toFixed(2)
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return flagged.sort((a, b) => b.ratio - a.ratio).slice(0, 5);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function shouldRecordJourney(dayEntries, cap = JOURNEY_DAILY_CAP) {
|
|
249
|
+
return Array.isArray(dayEntries) && dayEntries.length < cap;
|
|
250
|
+
}
|
|
251
|
+
|
|
134
252
|
export default async function analyticsPlugin(fastify, options) {
|
|
135
253
|
const { authenticate, requireAdmin } = options.auth;
|
|
136
254
|
const settings = options.settings || {};
|
|
137
255
|
|
|
138
256
|
await migrateLegacy();
|
|
139
257
|
|
|
258
|
+
fastify.decorate('analytics', {
|
|
259
|
+
async getStats({ range = '7d', from, to } = {}) {
|
|
260
|
+
const dates = rangeToDates(range, from, to);
|
|
261
|
+
const [lifetime, daily] = await Promise.all([
|
|
262
|
+
readJson(LIFETIME_FILE, {}),
|
|
263
|
+
readJson(DAILY_FILE, {})
|
|
264
|
+
]);
|
|
265
|
+
let totals; let total;
|
|
266
|
+
if (!dates) {
|
|
267
|
+
totals = lifetime;
|
|
268
|
+
total = Object.values(lifetime).reduce((a, n) => a + n, 0);
|
|
269
|
+
} else {
|
|
270
|
+
const agg = aggregateDaily(daily, dates.start, dates.end);
|
|
271
|
+
totals = agg.totals;
|
|
272
|
+
total = agg.total;
|
|
273
|
+
}
|
|
274
|
+
const top = Object.entries(totals)
|
|
275
|
+
.map(([url, hits]) => ({ url, hits }))
|
|
276
|
+
.sort((a, b) => b.hits - a.hits);
|
|
277
|
+
return { total, top, lifetimeTotal: Object.values(lifetime).reduce((a, n) => a + n, 0) };
|
|
278
|
+
},
|
|
279
|
+
async getDaily() { return readJson(DAILY_FILE, {}); },
|
|
280
|
+
async getJourneys({ range = '7d', from, to } = {}) {
|
|
281
|
+
const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
|
|
282
|
+
const journeys = await readJson(JOURNEYS_FILE, {});
|
|
283
|
+
return aggregateJourneys(eventsInRange(journeys, dates.start, dates.end));
|
|
284
|
+
},
|
|
285
|
+
async getRealtime() {
|
|
286
|
+
const journeys = await readJson(JOURNEYS_FILE, {});
|
|
287
|
+
const today = todayKey();
|
|
288
|
+
const events = journeys[today] || [];
|
|
289
|
+
const cutoff = Date.now() - REALTIME_WINDOW_MS;
|
|
290
|
+
const sids = new Set();
|
|
291
|
+
for (const ev of events) if (ev.t >= cutoff) sids.add(ev.sid);
|
|
292
|
+
return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
|
|
293
|
+
},
|
|
294
|
+
async getSpikes() {
|
|
295
|
+
const daily = await readJson(DAILY_FILE, {});
|
|
296
|
+
return { items: detectSpikes(daily) };
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
140
300
|
fastify.post('/hit', async (request, reply) => {
|
|
141
|
-
const { url, dnt } = request.body || {};
|
|
301
|
+
const { url, dnt, sid, ref } = request.body || {};
|
|
142
302
|
if (!url || typeof url !== 'string') {
|
|
143
303
|
return reply.status(400).send({ error: 'url is required' });
|
|
144
304
|
}
|
|
@@ -159,14 +319,31 @@ export default async function analyticsPlugin(fastify, options) {
|
|
|
159
319
|
}
|
|
160
320
|
|
|
161
321
|
const today = todayKey();
|
|
162
|
-
const [lifetime, daily] = await Promise.all([
|
|
322
|
+
const [lifetime, daily, journeys] = await Promise.all([
|
|
163
323
|
readJson(LIFETIME_FILE, {}),
|
|
164
|
-
readJson(DAILY_FILE, {})
|
|
324
|
+
readJson(DAILY_FILE, {}),
|
|
325
|
+
readJson(JOURNEYS_FILE, {})
|
|
165
326
|
]);
|
|
327
|
+
|
|
166
328
|
lifetime[normalised] = (lifetime[normalised] || 0) + 1;
|
|
167
329
|
if (!daily[today]) daily[today] = {};
|
|
168
330
|
daily[today][normalised] = (daily[today][normalised] || 0) + 1;
|
|
169
|
-
|
|
331
|
+
|
|
332
|
+
// Journey event — only if sid is a string of reasonable shape, and under cap
|
|
333
|
+
const safeSid = typeof sid === 'string' && sid.length > 0 && sid.length <= 64 ? sid : null;
|
|
334
|
+
const safeRef = typeof ref === 'string' && ref.length <= 512 ? ref : '';
|
|
335
|
+
if (safeSid) {
|
|
336
|
+
if (!journeys[today]) journeys[today] = [];
|
|
337
|
+
if (shouldRecordJourney(journeys[today])) {
|
|
338
|
+
journeys[today].push({ sid: safeSid, t: Date.now(), url: normalised, ref: safeRef });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
await Promise.all([
|
|
343
|
+
writeJson(LIFETIME_FILE, lifetime),
|
|
344
|
+
writeJson(DAILY_FILE, daily),
|
|
345
|
+
writeJson(JOURNEYS_FILE, journeys)
|
|
346
|
+
]);
|
|
170
347
|
return { ok: true };
|
|
171
348
|
});
|
|
172
349
|
|
|
@@ -212,6 +389,37 @@ export default async function analyticsPlugin(fastify, options) {
|
|
|
212
389
|
return { series: buildSeries(daily, dates.start, dates.end) };
|
|
213
390
|
});
|
|
214
391
|
|
|
392
|
+
fastify.get('/journeys', { preHandler: [authenticate, requireAdmin] }, async (request) => {
|
|
393
|
+
const { range = '7d', from, to } = request.query || {};
|
|
394
|
+
const dates = rangeToDates(range === 'all' ? '30d' : range, from, to);
|
|
395
|
+
const journeys = await readJson(JOURNEYS_FILE, {});
|
|
396
|
+
const events = eventsInRange(journeys, dates.start, dates.end);
|
|
397
|
+
const agg = aggregateJourneys(events);
|
|
398
|
+
return {
|
|
399
|
+
range,
|
|
400
|
+
from: todayKey(dates.start),
|
|
401
|
+
to: todayKey(dates.end),
|
|
402
|
+
...agg
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
fastify.get('/realtime', { preHandler: [authenticate, requireAdmin] }, async () => {
|
|
407
|
+
const journeys = await readJson(JOURNEYS_FILE, {});
|
|
408
|
+
const today = todayKey();
|
|
409
|
+
const events = journeys[today] || [];
|
|
410
|
+
const cutoff = Date.now() - REALTIME_WINDOW_MS;
|
|
411
|
+
const sids = new Set();
|
|
412
|
+
for (const ev of events) {
|
|
413
|
+
if (ev.t >= cutoff) sids.add(ev.sid);
|
|
414
|
+
}
|
|
415
|
+
return { activeSessions: sids.size, windowMinutes: REALTIME_WINDOW_MS / 60000 };
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
fastify.get('/spikes', { preHandler: [authenticate, requireAdmin] }, async () => {
|
|
419
|
+
const daily = await readJson(DAILY_FILE, {});
|
|
420
|
+
return { items: detectSpikes(daily) };
|
|
421
|
+
});
|
|
422
|
+
|
|
215
423
|
fastify.get('/export.csv', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
|
|
216
424
|
const { range = '30d', from, to } = request.query || {};
|
|
217
425
|
const dates = rangeToDates(range, from, to);
|
|
@@ -236,7 +444,11 @@ export default async function analyticsPlugin(fastify, options) {
|
|
|
236
444
|
});
|
|
237
445
|
|
|
238
446
|
fastify.delete('/stats', { preHandler: [authenticate, requireAdmin] }, async () => {
|
|
239
|
-
await Promise.all([
|
|
447
|
+
await Promise.all([
|
|
448
|
+
writeJson(LIFETIME_FILE, {}),
|
|
449
|
+
writeJson(DAILY_FILE, {}),
|
|
450
|
+
writeJson(JOURNEYS_FILE, {})
|
|
451
|
+
]);
|
|
240
452
|
try {
|
|
241
453
|
await fs.unlink(LEGACY_FILE);
|
|
242
454
|
} catch {
|
|
@@ -245,3 +457,6 @@ export default async function analyticsPlugin(fastify, options) {
|
|
|
245
457
|
return { ok: true };
|
|
246
458
|
});
|
|
247
459
|
}
|
|
460
|
+
|
|
461
|
+
export { aggregateJourneys, detectSpikes, shouldRecordJourney };
|
|
462
|
+
|
|
@@ -10,6 +10,21 @@
|
|
|
10
10
|
|
|
11
11
|
var url = window.location.pathname;
|
|
12
12
|
|
|
13
|
+
// Session-scoped anonymous ID — clears with the tab. Cookie-less.
|
|
14
|
+
var sid = '';
|
|
15
|
+
try {
|
|
16
|
+
sid = sessionStorage.getItem('dm_sid') || '';
|
|
17
|
+
if (!sid) {
|
|
18
|
+
sid = (crypto && crypto.randomUUID)
|
|
19
|
+
? crypto.randomUUID()
|
|
20
|
+
: (Date.now().toString(36) + Math.random().toString(36).slice(2, 10));
|
|
21
|
+
sessionStorage.setItem('dm_sid', sid);
|
|
22
|
+
}
|
|
23
|
+
} catch (e) {
|
|
24
|
+
// sessionStorage unavailable — proceed without sid
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Session dedup (one hit per URL per session)
|
|
13
28
|
try {
|
|
14
29
|
var key = 'dm_analytics_seen';
|
|
15
30
|
var seen = sessionStorage.getItem(key);
|
|
@@ -18,14 +33,23 @@
|
|
|
18
33
|
list.push(url);
|
|
19
34
|
sessionStorage.setItem(key, list.join('|'));
|
|
20
35
|
} catch (e) {
|
|
21
|
-
//
|
|
36
|
+
// proceed anyway
|
|
22
37
|
}
|
|
23
38
|
|
|
39
|
+
// Strip query/fragment from referrer; only keep same-origin or external origin+path
|
|
40
|
+
var ref = '';
|
|
41
|
+
try {
|
|
42
|
+
if (document.referrer) {
|
|
43
|
+
var r = new URL(document.referrer);
|
|
44
|
+
ref = r.origin === window.location.origin ? r.pathname : (r.origin + r.pathname);
|
|
45
|
+
}
|
|
46
|
+
} catch (e) { /* ignore */ }
|
|
47
|
+
|
|
24
48
|
fetch('/api/plugins/analytics/hit', {
|
|
25
49
|
method: 'POST',
|
|
26
50
|
headers: { 'Content-Type': 'application/json' },
|
|
27
51
|
keepalive: true,
|
|
28
|
-
body: JSON.stringify({ url: url })
|
|
52
|
+
body: JSON.stringify({ url: url, sid: sid, ref: ref })
|
|
29
53
|
}).catch(function () { /* silent */ });
|
|
30
54
|
})();
|
|
31
55
|
</script>
|
package/public/js/forms.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
const targets=document.querySelectorAll("[data-form]");targets.length&&targets.forEach(initFormTarget);function showMessage(
|
|
1
|
+
const targets=document.querySelectorAll("[data-form]");targets.length&&targets.forEach(initFormTarget);function showMessage(c,s,t){const e=c.querySelector(".fb-form-success, .fb-form-error");e&&e.remove();const i=document.createElement("div");i.className=t==="success"?"fb-form-success":"fb-form-error",i.textContent=s,c.appendChild(i)}function attachRuntimeLifecycle(c,s){if(c._formLogicRuntime=s,!c.parentNode||typeof MutationObserver>"u")return;const t=new MutationObserver(function(e){for(const i of e)for(const n of i.removedNodes)if(n===c||n.nodeType===1&&n.contains&&n.contains(c)){s.destroy(),t.disconnect();return}});t.observe(c.parentNode,{childList:!0,subtree:!1})}function buildBlueprintFromFields(c,s){const t={};return c.forEach(function(e){if(e.type==="page-break"||e.type==="spacer"||!e.name)return;const i=e.type==="checkbox"?"boolean":e.type==="date"?"string":e.type,n={...e.formConfig||{}};n.span==="full"&&s&&(n.span=s);const r={type:i,label:e.label||e.name,required:e.required||!1,options:e.options,formConfig:{...e.placeholder&&{placeholder:e.placeholder},...e.helper&&{helperText:e.helper},...e.tooltip&&{tooltip:e.tooltip},...n}};e.minLength!==void 0&&(r.minLength=e.minLength),e.maxLength!==void 0&&(r.maxLength=e.maxLength),e.min!==void 0&&(r.min=e.min),e.max!==void 0&&(r.max=e.max),e.type==="chooser"&&(e.variant&&(r.variant=e.variant),e.multiple&&(r.multiple=!0),e.density&&(r.density=e.density),e.columns&&(r.columns=e.columns),e.accent&&(r.accent=e.accent),e.accentStyle&&(r.accentStyle=e.accentStyle),e.glow&&(r.glow=!0),e.glowColour&&(r.glowColour=e.glowColour),e.shadow&&(r.shadow=e.shadow),e.shadowColour&&(r.shadowColour=e.shadowColour)),t[e.name]=r}),t}function buildInitialData(c){const s={};return c.forEach(function(t){if(!(!t.name||t.type==="page-break"||t.type==="spacer")&&(t.type==="select"||t.type==="multiselect")&&t.required){const e=(t.options||[])[0];e&&(s[t.name]=typeof e=="object"?e.value:e)}}),s}function patchDateInputs(c,s){(s||[]).forEach(function(t){if(t.type!=="date"||!t.name)return;const e=c.querySelector('[name="'+t.name+'"]');e&&e.type!=="date"&&(e.type="date")})}function buildWizardSteps(c,s){const t=[];let e=[],i=s||"Step 1",n="";return c.forEach(function(r){r.type==="page-break"?(t.push({title:i,description:n,fieldGroup:e}),e=[],i=r.label||"Step "+(t.length+1),n=r.description||""):r.type!=="spacer"&&e.push(r)}),t.push({title:i,description:n,fieldGroup:e}),t}function injectHoneypot(c){const s=document.createElement("div");s.className="fb-form-honeypot",s.setAttribute("aria-hidden","true");const t=document.createElement("input");t.name="website",t.type="text",t.tabIndex=-1,t.autocomplete="url",t.placeholder="https://",s.appendChild(t);const e=document.createElement("input");e.name="_t",e.type="hidden",e.value=Date.now(),s.appendChild(e),c.appendChild(s)}function injectSpacers(c,s){const t=c.querySelector("form");if(!t)return;const e=Array.from(t.querySelectorAll(".form-group"));let i=0;s.forEach(function(n){if(n.type==="spacer"){const r=document.createElement("div");r.className="fb-spacer";const a=e[i];if(a)t.insertBefore(r,a);else{const l=t.querySelector('[type="submit"]');l?t.insertBefore(r,l):t.appendChild(r)}}else n.type!=="page-break"&&i++})}function submitForm(c,s,t,e,i){const n=i||e,r=n.querySelector('[name="website"]')?.value||"",a=n.querySelector('[name="_t"]')?.value||"",l=Object.assign({},s,{_hp:r,_t:a});return fetch("/api/forms/submit/"+c,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)}).then(m=>m.json().then(u=>({ok:m.ok,body:u}))).then(m=>{m.ok&&m.body.ok?(e.textContent="",showMessage(e,m.body.message||t.successMessage||"Thank you.","success")):showMessage(e,m.body.error||"Something went wrong.","error")}).catch(()=>{showMessage(e,"Unable to submit. Please check your connection.","error")})}function renderManualForm(c,s,t,e,i){const n=document.createElement("form");n.noValidate=!0,s.forEach(function(a){const l=document.createElement("div");l.className="form-group",l.style.marginBottom="1.25rem";const m=document.createElement("label");if(m.className="form-label",m.textContent=a.label||a.name,a.required){const o=document.createElement("span");o.textContent=" *",o.style.color="#f87171",m.appendChild(o)}let u;a.type==="textarea"?(u=document.createElement("textarea"),u.rows=a.formConfig?.rows||4,u.className="form-input"):a.type==="select"?(u=document.createElement("select"),u.className="form-input",(a.options||[]).forEach(function(o){const p=document.createElement("option");p.value=typeof o=="string"?o:o.value??"",p.textContent=typeof o=="string"?o:o.label||p.value,u.appendChild(p)})):(u=document.createElement("input"),u.type=a.type||"text",u.className="form-input",a.placeholder&&(u.placeholder=a.placeholder)),u.name=a.name,u.required=a.required||!1,l.appendChild(m),l.appendChild(u),n.appendChild(l)}),t.honeypot&&injectHoneypot(n);const r=document.createElement("button");r.type="submit",r.className="btn btn-primary",r.textContent=t.submitText||"Submit",n.appendChild(r),n.addEventListener("submit",function(a){a.preventDefault();const l={};if(s.forEach(function(m){const u=n.querySelector('[name="'+m.name+'"]');u&&(l[m.name]=u.value)}),window.FormLogicEngine&&i){const m=window.FormLogicEngine,u=[],o=[];if(s.forEach(function(p){if(m.evaluateFieldVisibility(p,l)==="hidden"){delete l[p.name];return}const d=m.evaluateFieldRequirement(p,l),f=l[p.name];d&&(!f||!String(f).trim())&&u.push(p.label||p.name);const h=m.validateField(p,f||"",l);h.length&&o.push(h[0].message)}),u.length||o.length){const p=[];u.length&&p.push("Required: "+u.join(", ")),o.length&&p.push(o.join("; ")),showMessage(c,p.join(". "),"error");return}}c.classList.add("fb-form-loading"),r.disabled=!0,submitForm(e,l,t,c,n).finally(function(){c.classList.remove("fb-form-loading"),r.disabled=!1})}),c.appendChild(n),window.FormLogicEngine&&i&&s.some(a=>a.logic)&&new window.FormLogicEngine.FormLogicRuntime(i,c).init()}function initFormTarget(c){const s=c.getAttribute("data-form");s&&fetch("/api/forms/"+s+"/public").then(t=>{if(!t.ok)throw new Error("Form not found: "+s);return t.json()}).then(t=>{const e=t.fields||[],i=t.settings||{},n=document.createElement("div");n.className="fb-form-wrapper",c.appendChild(n);const r=e.some(a=>a.type==="page-break");if(typeof Domma<"u"&&Domma.forms){const a=i.columns||1;if(r&&Domma.forms.wizard){const m=buildWizardSteps(e,t.title).map(function(o){return{title:o.title,description:o.description,fields:buildBlueprintFromFields(o.fieldGroup,a)}}),u=Domma.forms.wizard(n,{schema:{steps:m},onSubmit:function(o){return submitForm(s,o,i,n,null)}});Promise.resolve(u).then(function(){if(patchDateInputs(n,e),window.FormLogicEngine&&e.some(o=>o.logic)){const o=new window.FormLogicEngine.FormLogicRuntime(t,n);o.init(),attachRuntimeLifecycle(n,o)}if(i.honeypot){const o=n.querySelector("form");o&&injectHoneypot(o)}})}else if(Domma.forms.render){const l=buildBlueprintFromFields(e,a),m=buildInitialData(e),u=Domma.forms.render(n,l,m,{submitText:i.submitText||"Submit",layout:i.layout||"stacked",columns:a,onSubmit:function(o){return submitForm(s,o,i,n,null)}});Promise.resolve(u).then(function(){if(patchDateInputs(n,e),window.FormLogicEngine&&e.some(o=>o.logic)){const o=new window.FormLogicEngine.FormLogicRuntime(t,n);o.init(),attachRuntimeLifecycle(n,o)}if(e.some(o=>o.type==="spacer")&&injectSpacers(n,e),i.honeypot){const o=n.querySelector("form");o&&injectHoneypot(o)}})}}else renderManualForm(n,e.filter(a=>a.type!=="page-break"&&a.type!=="spacer"),i,s,t)}).catch(t=>{const e=document.createElement("p");e.textContent="Form unavailable.",e.style.cssText="color:#f87171;font-style:italic;",c.appendChild(e),console.warn("[forms]",t.message)})}
|