@orion-studios/cms 0.5.7 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analytics/react.d.ts +3 -1
- package/dist/analytics/react.js +5 -0
- package/dist/chunk-DPKXKG2S.js +8 -0
- package/dist/{chunk-ULE565KD.js → chunk-Z52R6XR7.js} +227 -72
- package/dist/forms/react.d.ts +12 -2
- package/dist/forms/react.js +6 -3
- package/dist/server/index.d.ts +10 -4
- package/dist/server/index.js +96 -27
- package/dist/studio/index.js +4 -2
- package/package.json +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
declare const ANALYTICS_READY_EVENT = "orion-analytics-ready";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The site-side analytics tracker: small, first-party, and zero dependency.
|
|
3
5
|
* Mount <Analytics /> once in the site layout. It captures:
|
|
@@ -50,4 +52,4 @@ declare function AnalyticsNotFound(): null;
|
|
|
50
52
|
/** Programmatic tracking for custom site components. No-op when analytics is off. */
|
|
51
53
|
declare function trackEvent(name: string, meta?: Record<string, unknown>): void;
|
|
52
54
|
|
|
53
|
-
export { ANALYTICS_CONSENT_COOKIE, ANALYTICS_CONSENT_EVENT, ANALYTICS_VISITOR_COOKIE, Analytics, type AnalyticsConsent, AnalyticsNotFound, type AnalyticsProps, EXCLUDE_FLAG, getAnalyticsConsent, setAnalyticsConsent, trackEvent };
|
|
55
|
+
export { ANALYTICS_CONSENT_COOKIE, ANALYTICS_CONSENT_EVENT, ANALYTICS_READY_EVENT, ANALYTICS_VISITOR_COOKIE, Analytics, type AnalyticsConsent, AnalyticsNotFound, type AnalyticsProps, EXCLUDE_FLAG, getAnalyticsConsent, setAnalyticsConsent, trackEvent };
|
package/dist/analytics/react.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
"use client";
|
|
3
|
+
import {
|
|
4
|
+
ANALYTICS_READY_EVENT
|
|
5
|
+
} from "../chunk-DPKXKG2S.js";
|
|
3
6
|
|
|
4
7
|
// src/analytics/react.tsx
|
|
5
8
|
import { useEffect, useRef, useState } from "react";
|
|
@@ -105,6 +108,7 @@ function Analytics({
|
|
|
105
108
|
track({ type, name, path: window.location.pathname, meta });
|
|
106
109
|
if (type === "form" || type === "not_found") flush();
|
|
107
110
|
};
|
|
111
|
+
window.dispatchEvent(new Event(ANALYTICS_READY_EVENT));
|
|
108
112
|
const onClick = (event) => {
|
|
109
113
|
const target = event.target;
|
|
110
114
|
const tagged = target?.closest("[data-analytics]");
|
|
@@ -185,6 +189,7 @@ function trackEvent(name, meta) {
|
|
|
185
189
|
export {
|
|
186
190
|
ANALYTICS_CONSENT_COOKIE,
|
|
187
191
|
ANALYTICS_CONSENT_EVENT,
|
|
192
|
+
ANALYTICS_READY_EVENT,
|
|
188
193
|
ANALYTICS_VISITOR_COOKIE,
|
|
189
194
|
Analytics,
|
|
190
195
|
AnalyticsNotFound,
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
'use client';
|
|
2
|
+
import {
|
|
3
|
+
ANALYTICS_READY_EVENT
|
|
4
|
+
} from "./chunk-DPKXKG2S.js";
|
|
2
5
|
|
|
3
6
|
// src/forms/react.tsx
|
|
4
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState
|
|
13
|
+
} from "react";
|
|
5
14
|
|
|
6
15
|
// src/forms/validation.ts
|
|
7
16
|
var MAJOR_EMAIL_PROVIDERS = [
|
|
@@ -248,8 +257,135 @@ function getAutoReplyEmailFields(config) {
|
|
|
248
257
|
return [...fieldTypes.entries()].filter(([, types]) => types.size === 1 && types.has("email")).map(([name]) => name);
|
|
249
258
|
}
|
|
250
259
|
|
|
260
|
+
// src/forms/funnel.ts
|
|
261
|
+
var FORM_VIEW_MIN_VISIBLE_RATIO = 0.75;
|
|
262
|
+
var FORM_VIEW_DWELL_MS = 2e3;
|
|
263
|
+
function createFormFunnelSession(trackStage) {
|
|
264
|
+
const trackedStages = /* @__PURE__ */ new Set();
|
|
265
|
+
const trackOnce = (stage) => {
|
|
266
|
+
if (trackedStages.has(stage) || !trackStage(stage)) return false;
|
|
267
|
+
trackedStages.add(stage);
|
|
268
|
+
return true;
|
|
269
|
+
};
|
|
270
|
+
return {
|
|
271
|
+
hasViewed: () => trackedStages.has("viewed"),
|
|
272
|
+
trackStarted: () => trackOnce("start"),
|
|
273
|
+
trackViewed: () => trackOnce("viewed")
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function isQualifiedFormVisibility(intersectionRatio, isIntersecting, visibilityState) {
|
|
277
|
+
return isIntersecting && intersectionRatio >= FORM_VIEW_MIN_VISIBLE_RATIO && visibilityState === "visible";
|
|
278
|
+
}
|
|
279
|
+
|
|
251
280
|
// src/forms/react.tsx
|
|
252
281
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
282
|
+
var analyticsTrack = () => window.__orionTrack;
|
|
283
|
+
var isActionableFieldTarget = (target) => {
|
|
284
|
+
if (!(target instanceof Element)) return false;
|
|
285
|
+
const directField = target.closest("input, select, textarea");
|
|
286
|
+
const labelledField = target.closest("label")?.control;
|
|
287
|
+
const field = directField || labelledField;
|
|
288
|
+
if (!(field instanceof HTMLInputElement || field instanceof HTMLSelectElement || field instanceof HTMLTextAreaElement) || field.name === HONEYPOT_FIELD_NAME || field.disabled || "readOnly" in field && field.readOnly) {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
};
|
|
293
|
+
function useFormFunnel(slug, options = {}) {
|
|
294
|
+
const { preview = false } = options;
|
|
295
|
+
const [attentionTarget, setAttentionTarget] = useState(null);
|
|
296
|
+
const visibleRef = useRef(false);
|
|
297
|
+
const timerRef = useRef(null);
|
|
298
|
+
const clearViewTimer = useCallback(() => {
|
|
299
|
+
if (timerRef.current === null) return;
|
|
300
|
+
window.clearTimeout(timerRef.current);
|
|
301
|
+
timerRef.current = null;
|
|
302
|
+
}, []);
|
|
303
|
+
const trackStage = useCallback(
|
|
304
|
+
(stage) => {
|
|
305
|
+
if (preview || typeof window === "undefined") return false;
|
|
306
|
+
const track = analyticsTrack();
|
|
307
|
+
if (!track) return false;
|
|
308
|
+
track("form", `${slug}:${stage}`);
|
|
309
|
+
return true;
|
|
310
|
+
},
|
|
311
|
+
[preview, slug]
|
|
312
|
+
);
|
|
313
|
+
const session = useMemo(() => createFormFunnelSession(trackStage), [trackStage]);
|
|
314
|
+
const trackViewed = useCallback(() => {
|
|
315
|
+
if (session.trackViewed()) clearViewTimer();
|
|
316
|
+
}, [clearViewTimer, session]);
|
|
317
|
+
const startViewTimer = useCallback(() => {
|
|
318
|
+
if (session.hasViewed() || timerRef.current !== null || !visibleRef.current || document.visibilityState !== "visible" || !analyticsTrack()) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
timerRef.current = window.setTimeout(() => {
|
|
322
|
+
timerRef.current = null;
|
|
323
|
+
if (visibleRef.current && document.visibilityState === "visible") trackViewed();
|
|
324
|
+
}, FORM_VIEW_DWELL_MS);
|
|
325
|
+
}, [session, trackViewed]);
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
visibleRef.current = false;
|
|
328
|
+
clearViewTimer();
|
|
329
|
+
if (preview || !attentionTarget || typeof IntersectionObserver === "undefined") return;
|
|
330
|
+
const observer = new IntersectionObserver(
|
|
331
|
+
([entry]) => {
|
|
332
|
+
visibleRef.current = Boolean(
|
|
333
|
+
entry && entry.isIntersecting && entry.intersectionRatio >= FORM_VIEW_MIN_VISIBLE_RATIO
|
|
334
|
+
);
|
|
335
|
+
if (entry && isQualifiedFormVisibility(
|
|
336
|
+
entry.intersectionRatio,
|
|
337
|
+
entry.isIntersecting,
|
|
338
|
+
document.visibilityState
|
|
339
|
+
)) {
|
|
340
|
+
startViewTimer();
|
|
341
|
+
} else clearViewTimer();
|
|
342
|
+
},
|
|
343
|
+
{ threshold: FORM_VIEW_MIN_VISIBLE_RATIO }
|
|
344
|
+
);
|
|
345
|
+
const onVisibilityChange = () => {
|
|
346
|
+
if (document.visibilityState !== "visible") {
|
|
347
|
+
clearViewTimer();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (visibleRef.current) startViewTimer();
|
|
351
|
+
};
|
|
352
|
+
const onAnalyticsReady = () => {
|
|
353
|
+
if (visibleRef.current) startViewTimer();
|
|
354
|
+
};
|
|
355
|
+
observer.observe(attentionTarget);
|
|
356
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
357
|
+
window.addEventListener(ANALYTICS_READY_EVENT, onAnalyticsReady);
|
|
358
|
+
return () => {
|
|
359
|
+
observer.disconnect();
|
|
360
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
361
|
+
window.removeEventListener(ANALYTICS_READY_EVENT, onAnalyticsReady);
|
|
362
|
+
clearViewTimer();
|
|
363
|
+
};
|
|
364
|
+
}, [attentionTarget, clearViewTimer, preview, slug, startViewTimer]);
|
|
365
|
+
const onFieldPointerDownCapture = useCallback(
|
|
366
|
+
(event) => {
|
|
367
|
+
if (!event.nativeEvent.isTrusted || !isActionableFieldTarget(event.target)) return;
|
|
368
|
+
trackViewed();
|
|
369
|
+
},
|
|
370
|
+
[trackViewed]
|
|
371
|
+
);
|
|
372
|
+
const onFieldKeyDownCapture = useCallback(
|
|
373
|
+
(event) => {
|
|
374
|
+
if (!event.nativeEvent.isTrusted || !isActionableFieldTarget(event.target)) return;
|
|
375
|
+
trackViewed();
|
|
376
|
+
},
|
|
377
|
+
[trackViewed]
|
|
378
|
+
);
|
|
379
|
+
const trackStarted = useCallback(() => {
|
|
380
|
+
session.trackStarted();
|
|
381
|
+
}, [session]);
|
|
382
|
+
return {
|
|
383
|
+
attentionRef: setAttentionTarget,
|
|
384
|
+
onFieldKeyDownCapture,
|
|
385
|
+
onFieldPointerDownCapture,
|
|
386
|
+
trackStarted
|
|
387
|
+
};
|
|
388
|
+
}
|
|
253
389
|
var DEFAULT_CLASSES = {
|
|
254
390
|
form: "ocf-form",
|
|
255
391
|
field: "ocf-field",
|
|
@@ -291,26 +427,17 @@ function FormRenderer({
|
|
|
291
427
|
const [state, setState] = useState("idle");
|
|
292
428
|
const [honeypot, setHoneypot] = useState("");
|
|
293
429
|
const renderedAt = useRef(Date.now());
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
);
|
|
301
|
-
};
|
|
302
|
-
useEffect(() => {
|
|
303
|
-
const timer = setTimeout(() => funnel("view"), 100);
|
|
304
|
-
return () => clearTimeout(timer);
|
|
305
|
-
}, [slug]);
|
|
430
|
+
const {
|
|
431
|
+
attentionRef,
|
|
432
|
+
onFieldKeyDownCapture,
|
|
433
|
+
onFieldPointerDownCapture,
|
|
434
|
+
trackStarted
|
|
435
|
+
} = useFormFunnel(slug, { preview });
|
|
306
436
|
useEffect(() => {
|
|
307
437
|
setStepIndex((current) => Math.min(current, Math.max(steps.length - 1, 0)));
|
|
308
438
|
}, [steps.length]);
|
|
309
439
|
const setValue = (name, next, type) => {
|
|
310
|
-
|
|
311
|
-
startedRef.current = true;
|
|
312
|
-
funnel("start");
|
|
313
|
-
}
|
|
440
|
+
trackStarted();
|
|
314
441
|
setValues((current) => ({
|
|
315
442
|
...current,
|
|
316
443
|
[name]: type === "phone" && typeof next === "string" ? formatPhoneUS(next) : next
|
|
@@ -393,6 +520,7 @@ function FormRenderer({
|
|
|
393
520
|
}
|
|
394
521
|
const isLastStep = stepIndex >= steps.length - 1;
|
|
395
522
|
const visibleSteps = steps.length > 0 ? [steps[stepIndex]] : [];
|
|
523
|
+
const firstActionableFieldKey = visibleSteps.flatMap((step) => step.fields || []).map((field, index) => ({ field, key: fieldKey(field, index) })).find(({ field }) => inferFieldType(field) !== "hidden")?.key;
|
|
396
524
|
const renderField = (field, index) => {
|
|
397
525
|
const name = fieldKey(field, index);
|
|
398
526
|
const type = inferFieldType(field);
|
|
@@ -402,15 +530,23 @@ function FormRenderer({
|
|
|
402
530
|
const id = `ocf-${slug}-${name}`;
|
|
403
531
|
const value = values[name];
|
|
404
532
|
if (type === "hidden") return null;
|
|
405
|
-
const wrap = (control) => /* @__PURE__ */ jsxs(
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
field
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
533
|
+
const wrap = (control) => /* @__PURE__ */ jsxs(
|
|
534
|
+
"div",
|
|
535
|
+
{
|
|
536
|
+
className: `${cls.field}${error ? ` ${cls.fieldError}` : ""}`,
|
|
537
|
+
ref: name === firstActionableFieldKey ? attentionRef : void 0,
|
|
538
|
+
children: [
|
|
539
|
+
type !== "checkbox" || options.length > 0 ? /* @__PURE__ */ jsxs("label", { className: cls.label, htmlFor: id, children: [
|
|
540
|
+
label,
|
|
541
|
+
field.required ? " *" : ""
|
|
542
|
+
] }) : null,
|
|
543
|
+
control,
|
|
544
|
+
field.help ? /* @__PURE__ */ jsx("p", { className: "ocf-help", children: field.help }) : null,
|
|
545
|
+
error ? /* @__PURE__ */ jsx("p", { className: cls.errorText, role: "alert", children: error }) : null
|
|
546
|
+
]
|
|
547
|
+
},
|
|
548
|
+
name
|
|
549
|
+
);
|
|
414
550
|
if (type === "textarea") {
|
|
415
551
|
return wrap(
|
|
416
552
|
/* @__PURE__ */ jsx(
|
|
@@ -480,22 +616,30 @@ function FormRenderer({
|
|
|
480
616
|
);
|
|
481
617
|
}
|
|
482
618
|
if (type === "checkbox") {
|
|
483
|
-
return /* @__PURE__ */ jsxs(
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
619
|
+
return /* @__PURE__ */ jsxs(
|
|
620
|
+
"div",
|
|
621
|
+
{
|
|
622
|
+
className: `${cls.field}${error ? ` ${cls.fieldError}` : ""}`,
|
|
623
|
+
ref: name === firstActionableFieldKey ? attentionRef : void 0,
|
|
624
|
+
children: [
|
|
625
|
+
/* @__PURE__ */ jsxs("label", { className: cls.checkbox, htmlFor: id, children: [
|
|
626
|
+
/* @__PURE__ */ jsx(
|
|
627
|
+
"input",
|
|
628
|
+
{
|
|
629
|
+
checked: value === true,
|
|
630
|
+
id,
|
|
631
|
+
onChange: (event) => setValue(name, event.target.checked, type),
|
|
632
|
+
type: "checkbox"
|
|
633
|
+
}
|
|
634
|
+
),
|
|
635
|
+
label,
|
|
636
|
+
field.required ? " *" : ""
|
|
637
|
+
] }),
|
|
638
|
+
error ? /* @__PURE__ */ jsx("p", { className: cls.errorText, role: "alert", children: error }) : null
|
|
639
|
+
]
|
|
640
|
+
},
|
|
641
|
+
name
|
|
642
|
+
);
|
|
499
643
|
}
|
|
500
644
|
const inputType = type === "email" ? "email" : type === "date" ? "date" : type === "number" ? "text" : "text";
|
|
501
645
|
const inputMode = type === "phone" ? "tel" : type === "email" ? "email" : type === "number" ? "decimal" : void 0;
|
|
@@ -514,40 +658,51 @@ function FormRenderer({
|
|
|
514
658
|
)
|
|
515
659
|
);
|
|
516
660
|
};
|
|
517
|
-
return /* @__PURE__ */ jsxs(
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
661
|
+
return /* @__PURE__ */ jsxs(
|
|
662
|
+
"form",
|
|
663
|
+
{
|
|
664
|
+
className: cls.form,
|
|
665
|
+
noValidate: true,
|
|
666
|
+
onKeyDownCapture: onFieldKeyDownCapture,
|
|
667
|
+
onPointerDownCapture: onFieldPointerDownCapture,
|
|
668
|
+
onSubmit: submit,
|
|
669
|
+
children: [
|
|
670
|
+
title,
|
|
671
|
+
intro,
|
|
672
|
+
/* @__PURE__ */ jsx(
|
|
673
|
+
"input",
|
|
674
|
+
{
|
|
675
|
+
"aria-hidden": "true",
|
|
676
|
+
autoComplete: "off",
|
|
677
|
+
className: "ocf-honeypot",
|
|
678
|
+
name: HONEYPOT_FIELD_NAME,
|
|
679
|
+
onChange: (event) => setHoneypot(event.target.value),
|
|
680
|
+
style: { position: "absolute", left: "-9999px", height: 0, width: 0, opacity: 0 },
|
|
681
|
+
tabIndex: -1,
|
|
682
|
+
value: honeypot
|
|
683
|
+
}
|
|
684
|
+
),
|
|
685
|
+
steps.length > 1 ? /* @__PURE__ */ jsxs("p", { className: cls.stepTitle, children: [
|
|
686
|
+
"Step ",
|
|
687
|
+
stepIndex + 1,
|
|
688
|
+
" of ",
|
|
689
|
+
steps.length,
|
|
690
|
+
steps[stepIndex]?.title ? ` \u2014 ${steps[stepIndex].title}` : ""
|
|
691
|
+
] }) : null,
|
|
692
|
+
visibleSteps.map((step) => (step.fields || []).map(renderField)),
|
|
693
|
+
state === "error" ? /* @__PURE__ */ jsx("p", { className: cls.formError, role: "alert", children: "The request could not be sent. Please try again." }) : null,
|
|
694
|
+
/* @__PURE__ */ jsxs("div", { className: "ocf-actions", children: [
|
|
695
|
+
steps.length > 1 && stepIndex > 0 ? /* @__PURE__ */ jsx("button", { className: cls.button, onClick: () => setStepIndex(stepIndex - 1), type: "button", children: "Back" }) : null,
|
|
696
|
+
!isLastStep ? /* @__PURE__ */ jsx("button", { className: cls.button, onClick: nextStep, type: "button", children: "Next" }) : /* @__PURE__ */ jsx("button", { className: cls.button, disabled: state === "submitting" || preview, type: "submit", children: state === "submitting" ? "Sending\u2026" : submitLabel })
|
|
697
|
+
] })
|
|
698
|
+
]
|
|
699
|
+
}
|
|
700
|
+
);
|
|
547
701
|
}
|
|
548
702
|
|
|
549
703
|
export {
|
|
550
704
|
FORM_FIELD_TYPES,
|
|
551
705
|
getAutoReplyEmailFields,
|
|
706
|
+
useFormFunnel,
|
|
552
707
|
FormRenderer
|
|
553
708
|
};
|
package/dist/forms/react.d.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { ReactNode } from 'react';
|
|
2
|
+
import { KeyboardEvent, PointerEvent, ReactNode } from 'react';
|
|
3
3
|
import { F as FormConfig } from '../submission-CKZgx1h7.js';
|
|
4
4
|
|
|
5
|
+
type FormFunnelOptions = {
|
|
6
|
+
preview?: boolean;
|
|
7
|
+
};
|
|
8
|
+
type FormFunnelTracking = {
|
|
9
|
+
attentionRef: (node: HTMLElement | null) => void;
|
|
10
|
+
onFieldKeyDownCapture: (event: KeyboardEvent<HTMLElement>) => void;
|
|
11
|
+
onFieldPointerDownCapture: (event: PointerEvent<HTMLElement>) => void;
|
|
12
|
+
trackStarted: () => void;
|
|
13
|
+
};
|
|
14
|
+
declare function useFormFunnel(slug: string, options?: FormFunnelOptions): FormFunnelTracking;
|
|
5
15
|
/**
|
|
6
16
|
* The shared, config-driven form renderer: the same component renders a form
|
|
7
17
|
* in the Studio's form editor preview and on the public site, from the same
|
|
@@ -42,4 +52,4 @@ type FormRendererProps = {
|
|
|
42
52
|
};
|
|
43
53
|
declare function FormRenderer({ slug, config, successMessage, basePath, classNames, title, intro, submitLabel, preview, onSuccess, }: FormRendererProps): react.JSX.Element;
|
|
44
54
|
|
|
45
|
-
export { FormRenderer, type FormRendererClassNames, type FormRendererProps };
|
|
55
|
+
export { type FormFunnelOptions, type FormFunnelTracking, FormRenderer, type FormRendererClassNames, type FormRendererProps, useFormFunnel };
|
package/dist/forms/react.js
CHANGED
package/dist/server/index.d.ts
CHANGED
|
@@ -183,8 +183,8 @@ declare function getPreviewPage(client: SupabaseClient, token: string, keys: Pre
|
|
|
183
183
|
* and memory backends and is trivially testable.
|
|
184
184
|
*
|
|
185
185
|
* Call and email taps are useful interaction counts, but browser events are
|
|
186
|
-
* forgeable.
|
|
187
|
-
*
|
|
186
|
+
* forgeable. Form conversions come from authoritative submission rows. Any
|
|
187
|
+
* other conversion requires a server-verified event.
|
|
188
188
|
*/
|
|
189
189
|
type StoredEvent = {
|
|
190
190
|
id?: number | string;
|
|
@@ -202,6 +202,12 @@ type StoredEvent = {
|
|
|
202
202
|
server_verified?: boolean;
|
|
203
203
|
created_at: string;
|
|
204
204
|
};
|
|
205
|
+
/** Minimal authoritative form record used by aggregate analytics. */
|
|
206
|
+
type StoredFormSubmission = {
|
|
207
|
+
form: string;
|
|
208
|
+
session_key: string;
|
|
209
|
+
created_at: string;
|
|
210
|
+
};
|
|
205
211
|
type AnalyticsKpis = {
|
|
206
212
|
visitors: number;
|
|
207
213
|
identifiedVisitors: number;
|
|
@@ -267,7 +273,7 @@ type AnalyticsSummary = {
|
|
|
267
273
|
declare function aggregateAnalytics(events: StoredEvent[], previousEvents: StoredEvent[], range: {
|
|
268
274
|
from: string;
|
|
269
275
|
to: string;
|
|
270
|
-
}): AnalyticsSummary;
|
|
276
|
+
}, submissions?: StoredFormSubmission[], previousSubmissions?: StoredFormSubmission[]): AnalyticsSummary;
|
|
271
277
|
|
|
272
278
|
/**
|
|
273
279
|
* Server-side analytics ingest: validation, bot filtering, and the
|
|
@@ -489,4 +495,4 @@ declare function createMemoryCms(): MemoryCms;
|
|
|
489
495
|
/** Process-wide singleton for Next.js dev servers (module state survives HMR via globalThis). */
|
|
490
496
|
declare function getMemoryCms(): MemoryCms;
|
|
491
497
|
|
|
492
|
-
export { type AnalyticsEventType, type AnalyticsKpis, type AnalyticsSummary, type CmsAction, type CmsEnv, type CmsRole, type CmsRoutesOptions, type CmsUser, type EmailMessage, type EmailSender, type IncomingEvent, MEMORY_DEV_TOKEN, type MemoryCms, PREVIEW_SESSION_COOKIE, PREVIEW_TOKEN_MAX_USES, PREVIEW_TOKEN_TTL_MS, type PreviewGrantClaims, type PreviewPage, type ResendSenderOptions, type StoredEvent, type SyncFormInput, type SyncGlobalInput, type SyncInput, type SyncMediaInput, type SyncOperation, type SyncOptions, type SyncPageInput, type SyncPlan, type SyncResult, aggregateAnalytics, can, createCmsRoutes, createDurableRateLimitStore, createMemoryCms, createPreviewToken, createResendSender, deviceFrom, formatSubmissionText, geoFrom, getMemoryCms, getPreviewPage, getServiceClient, isBotRequest, isStructuralChange, notifySubmission, parseEventBatch, readCmsEnv, resolveUser, runContentSync, sessionKeyFor, setServiceClientForTesting, validateCmsEnv, verifyPreviewGrantToken, verifyPreviewToken, visitorKeyFor };
|
|
498
|
+
export { type AnalyticsEventType, type AnalyticsKpis, type AnalyticsSummary, type CmsAction, type CmsEnv, type CmsRole, type CmsRoutesOptions, type CmsUser, type EmailMessage, type EmailSender, type IncomingEvent, MEMORY_DEV_TOKEN, type MemoryCms, PREVIEW_SESSION_COOKIE, PREVIEW_TOKEN_MAX_USES, PREVIEW_TOKEN_TTL_MS, type PreviewGrantClaims, type PreviewPage, type ResendSenderOptions, type StoredEvent, type StoredFormSubmission, type SyncFormInput, type SyncGlobalInput, type SyncInput, type SyncMediaInput, type SyncOperation, type SyncOptions, type SyncPageInput, type SyncPlan, type SyncResult, aggregateAnalytics, can, createCmsRoutes, createDurableRateLimitStore, createMemoryCms, createPreviewToken, createResendSender, deviceFrom, formatSubmissionText, geoFrom, getMemoryCms, getPreviewPage, getServiceClient, isBotRequest, isStructuralChange, notifySubmission, parseEventBatch, readCmsEnv, resolveUser, runContentSync, sessionKeyFor, setServiceClientForTesting, validateCmsEnv, verifyPreviewGrantToken, verifyPreviewToken, visitorKeyFor };
|
package/dist/server/index.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import {
|
|
2
|
-
CONTENT_CACHE_TAG
|
|
3
|
-
} from "../chunk-NSAZCP4I.js";
|
|
4
1
|
import {
|
|
5
2
|
createMemoryRateLimitStore,
|
|
6
3
|
getAutoReplyEmailFields,
|
|
@@ -8,6 +5,9 @@ import {
|
|
|
8
5
|
processSubmission,
|
|
9
6
|
resolveAutoReplyEmailField
|
|
10
7
|
} from "../chunk-CFZP7674.js";
|
|
8
|
+
import {
|
|
9
|
+
CONTENT_CACHE_TAG
|
|
10
|
+
} from "../chunk-NSAZCP4I.js";
|
|
11
11
|
|
|
12
12
|
// src/server/routes.ts
|
|
13
13
|
import { createHash as createHash3, createHmac as createHmac3, randomUUID, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
@@ -40,9 +40,10 @@ function normalizeSource(utmSource, referrer) {
|
|
|
40
40
|
if (host.includes("twitter.") || host === "t.co" || host.includes("x.com")) return "X (Twitter)";
|
|
41
41
|
return host;
|
|
42
42
|
}
|
|
43
|
-
var
|
|
43
|
+
var isFormSubmitEvent = (event) => event.type === "form" && event.name.endsWith(":submit");
|
|
44
|
+
var isConversion = (event) => event.server_verified === true && (event.type === "click" && CONVERSION_NAMES.has(event.name) || isFormSubmitEvent(event));
|
|
44
45
|
var top = (map, limit, by) => [...map.entries()].sort((a, b) => by(b[1]) - by(a[1])).slice(0, limit);
|
|
45
|
-
function computeKpis(events) {
|
|
46
|
+
function computeKpis(events, submissions) {
|
|
46
47
|
const sessions = /* @__PURE__ */ new Set();
|
|
47
48
|
const visitors = /* @__PURE__ */ new Set();
|
|
48
49
|
const visitorSessions = /* @__PURE__ */ new Map();
|
|
@@ -65,12 +66,15 @@ function computeKpis(events) {
|
|
|
65
66
|
if (event.type === "click" && event.name === "call") calls += 1;
|
|
66
67
|
if (event.type === "click" && event.name === "email") emails += 1;
|
|
67
68
|
if (event.type === "click" && event.name === "portal") portalClicks += 1;
|
|
68
|
-
if (event.server_verified === true && event
|
|
69
|
-
formSubmits += 1;
|
|
70
|
-
}
|
|
69
|
+
if (event.server_verified === true && isFormSubmitEvent(event)) formSubmits += 1;
|
|
71
70
|
if (isConversion(event) && event.session_key) converting.add(event.session_key);
|
|
72
71
|
}
|
|
73
|
-
const
|
|
72
|
+
for (const submission of submissions || []) {
|
|
73
|
+
if (submission.session_key && sessions.has(submission.session_key)) {
|
|
74
|
+
converting.add(submission.session_key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const conversions = events.filter(isConversion).length + (submissions?.length ?? 0);
|
|
74
78
|
const returningVisitors = [...visitorSessions.values()].filter((keys) => keys.size > 1).length;
|
|
75
79
|
return {
|
|
76
80
|
visitors: visitors.size,
|
|
@@ -80,19 +84,21 @@ function computeKpis(events) {
|
|
|
80
84
|
pageviews,
|
|
81
85
|
pagesPerVisitor: visitors.size > 0 ? pageviews / visitors.size : 0,
|
|
82
86
|
calls,
|
|
83
|
-
formSubmits,
|
|
87
|
+
formSubmits: submissions?.length ?? formSubmits,
|
|
84
88
|
emails,
|
|
85
89
|
portalClicks,
|
|
86
90
|
conversions,
|
|
87
91
|
conversionRate: sessions.size > 0 ? converting.size / sessions.size : 0
|
|
88
92
|
};
|
|
89
93
|
}
|
|
90
|
-
function aggregateAnalytics(events, previousEvents, range) {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
94
|
+
function aggregateAnalytics(events, previousEvents, range, submissions, previousSubmissions) {
|
|
95
|
+
const trafficEvents = submissions === void 0 ? events : events.filter((event) => !isFormSubmitEvent(event));
|
|
96
|
+
const previousTrafficEvents = previousSubmissions === void 0 ? previousEvents : previousEvents.filter((event) => !isFormSubmitEvent(event));
|
|
97
|
+
const kpis = computeKpis(trafficEvents, submissions);
|
|
98
|
+
const previous = computeKpis(previousTrafficEvents, previousSubmissions);
|
|
93
99
|
const byDay = /* @__PURE__ */ new Map();
|
|
94
100
|
const dayOf = (iso) => iso.slice(0, 10);
|
|
95
|
-
for (const event of
|
|
101
|
+
for (const event of trafficEvents) {
|
|
96
102
|
const day = dayOf(event.created_at);
|
|
97
103
|
const entry = byDay.get(day) || { visitors: /* @__PURE__ */ new Set(), conversions: 0 };
|
|
98
104
|
const identity = event.visitor_key || event.session_key;
|
|
@@ -100,6 +106,12 @@ function aggregateAnalytics(events, previousEvents, range) {
|
|
|
100
106
|
if (isConversion(event)) entry.conversions += 1;
|
|
101
107
|
byDay.set(day, entry);
|
|
102
108
|
}
|
|
109
|
+
for (const submission of submissions || []) {
|
|
110
|
+
const day = dayOf(submission.created_at);
|
|
111
|
+
const entry = byDay.get(day) || { visitors: /* @__PURE__ */ new Set(), conversions: 0 };
|
|
112
|
+
entry.conversions += 1;
|
|
113
|
+
byDay.set(day, entry);
|
|
114
|
+
}
|
|
103
115
|
const trend = [];
|
|
104
116
|
for (let cursor = /* @__PURE__ */ new Date(`${dayOf(range.from)}T00:00:00Z`); cursor.toISOString().slice(0, 10) <= dayOf(range.to); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
|
|
105
117
|
const day = cursor.toISOString().slice(0, 10);
|
|
@@ -108,7 +120,7 @@ function aggregateAnalytics(events, previousEvents, range) {
|
|
|
108
120
|
if (trend.length > 370) break;
|
|
109
121
|
}
|
|
110
122
|
const bySession = /* @__PURE__ */ new Map();
|
|
111
|
-
for (const event of
|
|
123
|
+
for (const event of trafficEvents) {
|
|
112
124
|
if (!event.session_key) continue;
|
|
113
125
|
const list = bySession.get(event.session_key) || [];
|
|
114
126
|
list.push(event);
|
|
@@ -117,27 +129,39 @@ function aggregateAnalytics(events, previousEvents, range) {
|
|
|
117
129
|
for (const list of bySession.values()) {
|
|
118
130
|
list.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
119
131
|
}
|
|
132
|
+
const convertedSessionKeys = /* @__PURE__ */ new Set();
|
|
133
|
+
for (const event of trafficEvents) {
|
|
134
|
+
if (isConversion(event) && event.session_key) convertedSessionKeys.add(event.session_key);
|
|
135
|
+
}
|
|
136
|
+
for (const submission of submissions || []) {
|
|
137
|
+
if (submission.session_key) convertedSessionKeys.add(submission.session_key);
|
|
138
|
+
}
|
|
120
139
|
const pages = /* @__PURE__ */ new Map();
|
|
121
140
|
const pageEntry = (path) => {
|
|
122
141
|
const entry = pages.get(path) || { views: 0, entries: 0, conversions: 0 };
|
|
123
142
|
pages.set(path, entry);
|
|
124
143
|
return entry;
|
|
125
144
|
};
|
|
126
|
-
for (const event of
|
|
145
|
+
for (const event of trafficEvents) {
|
|
127
146
|
if (event.type === "pageview") pageEntry(event.path).views += 1;
|
|
128
147
|
if (isConversion(event)) pageEntry(event.path).conversions += 1;
|
|
129
148
|
}
|
|
149
|
+
for (const submission of submissions || []) {
|
|
150
|
+
const list = bySession.get(submission.session_key);
|
|
151
|
+
const lastPageview = list?.filter((event) => event.type === "pageview" && event.created_at <= submission.created_at).at(-1);
|
|
152
|
+
if (lastPageview) pageEntry(lastPageview.path).conversions += 1;
|
|
153
|
+
}
|
|
130
154
|
for (const list of bySession.values()) {
|
|
131
155
|
const first = list.find((event) => event.type === "pageview");
|
|
132
156
|
if (first) pageEntry(first.path).entries += 1;
|
|
133
157
|
}
|
|
134
158
|
const sources = /* @__PURE__ */ new Map();
|
|
135
|
-
for (const list of bySession.
|
|
159
|
+
for (const [sessionKey, list] of bySession.entries()) {
|
|
136
160
|
const first = list.find((event) => event.type === "pageview");
|
|
137
161
|
const label = normalizeSource(first?.utm?.source || "", first?.referrer || "");
|
|
138
162
|
const entry = sources.get(label) || { sessions: 0, conversions: 0 };
|
|
139
163
|
entry.sessions += 1;
|
|
140
|
-
if (
|
|
164
|
+
if (convertedSessionKeys.has(sessionKey)) entry.conversions += 1;
|
|
141
165
|
sources.set(label, entry);
|
|
142
166
|
}
|
|
143
167
|
const locations = /* @__PURE__ */ new Map();
|
|
@@ -149,13 +173,13 @@ function aggregateAnalytics(events, previousEvents, range) {
|
|
|
149
173
|
if (sample.device) devices.set(sample.device, (devices.get(sample.device) || 0) + 1);
|
|
150
174
|
}
|
|
151
175
|
const hours = new Array(24).fill(0);
|
|
152
|
-
for (const event of
|
|
176
|
+
for (const event of trafficEvents) {
|
|
153
177
|
if (event.type !== "pageview") continue;
|
|
154
178
|
const hour = new Date(event.created_at).getUTCHours();
|
|
155
179
|
if (Number.isFinite(hour)) hours[hour] += 1;
|
|
156
180
|
}
|
|
157
181
|
const pathCounts = /* @__PURE__ */ new Map();
|
|
158
|
-
for (const list of bySession.
|
|
182
|
+
for (const [sessionKey, list] of bySession.entries()) {
|
|
159
183
|
const steps = [];
|
|
160
184
|
for (const event of list) {
|
|
161
185
|
if (event.type !== "pageview") continue;
|
|
@@ -166,22 +190,28 @@ function aggregateAnalytics(events, previousEvents, range) {
|
|
|
166
190
|
const key = steps.join(" \u2192 ");
|
|
167
191
|
const entry = pathCounts.get(key) || { count: 0, converted: 0 };
|
|
168
192
|
entry.count += 1;
|
|
169
|
-
if (
|
|
193
|
+
if (convertedSessionKeys.has(sessionKey)) entry.converted += 1;
|
|
170
194
|
pathCounts.set(key, entry);
|
|
171
195
|
}
|
|
172
196
|
const forms = /* @__PURE__ */ new Map();
|
|
173
|
-
for (const event of
|
|
197
|
+
for (const event of trafficEvents) {
|
|
174
198
|
if (event.type !== "form") continue;
|
|
175
199
|
const [slug, stage] = event.name.split(":");
|
|
176
200
|
if (!slug || !stage) continue;
|
|
201
|
+
if (stage !== "viewed" && stage !== "start" && stage !== "submit") continue;
|
|
177
202
|
const entry = forms.get(slug) || { views: 0, starts: 0, submits: 0 };
|
|
178
|
-
if (stage === "
|
|
203
|
+
if (stage === "viewed") entry.views += 1;
|
|
179
204
|
if (stage === "start") entry.starts += 1;
|
|
180
205
|
if (stage === "submit" && event.server_verified === true) entry.submits += 1;
|
|
181
206
|
forms.set(slug, entry);
|
|
182
207
|
}
|
|
208
|
+
for (const submission of submissions || []) {
|
|
209
|
+
const entry = forms.get(submission.form) || { views: 0, starts: 0, submits: 0 };
|
|
210
|
+
entry.submits += 1;
|
|
211
|
+
forms.set(submission.form, entry);
|
|
212
|
+
}
|
|
183
213
|
const notFound = /* @__PURE__ */ new Map();
|
|
184
|
-
for (const event of
|
|
214
|
+
for (const event of trafficEvents) {
|
|
185
215
|
if (event.type === "not_found") notFound.set(event.path, (notFound.get(event.path) || 0) + 1);
|
|
186
216
|
}
|
|
187
217
|
return {
|
|
@@ -2251,6 +2281,30 @@ function createCmsRoutes(options) {
|
|
|
2251
2281
|
}
|
|
2252
2282
|
return all;
|
|
2253
2283
|
};
|
|
2284
|
+
const fetchFormSubmissions = async (fromIso, toIso) => {
|
|
2285
|
+
const all = [];
|
|
2286
|
+
let cursor = 0;
|
|
2287
|
+
for (let page = 0; page < 60; page += 1) {
|
|
2288
|
+
const { data, error } = await db().from("cms_form_submissions").select("id, form_id, session_key, created_at").gte("created_at", fromIso).lte("created_at", toIso).gt("id", cursor).order("id", { ascending: true }).limit(1e3);
|
|
2289
|
+
if (error) return all.length === 0 ? null : all;
|
|
2290
|
+
if (!data || data.length === 0) break;
|
|
2291
|
+
all.push(...data);
|
|
2292
|
+
cursor = Number(data[data.length - 1].id);
|
|
2293
|
+
if (data.length < 1e3) break;
|
|
2294
|
+
}
|
|
2295
|
+
return all;
|
|
2296
|
+
};
|
|
2297
|
+
const fetchFormSlugs = async () => {
|
|
2298
|
+
const { data, error } = await db().from("cms_forms").select("id, slug").limit(1e3);
|
|
2299
|
+
const slugs = /* @__PURE__ */ new Map();
|
|
2300
|
+
if (error || !data) return slugs;
|
|
2301
|
+
for (const form of data) {
|
|
2302
|
+
const id = String(form.id || "");
|
|
2303
|
+
const slug = String(form.slug || "");
|
|
2304
|
+
if (id && slug) slugs.set(id, slug);
|
|
2305
|
+
}
|
|
2306
|
+
return slugs;
|
|
2307
|
+
};
|
|
2254
2308
|
const getAnalytics = async (request) => {
|
|
2255
2309
|
const auth = await guard(request, "analytics.read");
|
|
2256
2310
|
if (auth instanceof Response) return auth;
|
|
@@ -2262,11 +2316,26 @@ function createCmsRoutes(options) {
|
|
|
2262
2316
|
const from = new Date(fromMs).toISOString();
|
|
2263
2317
|
const to = new Date(toMs).toISOString();
|
|
2264
2318
|
const previousFrom = new Date(fromMs - windowMs).toISOString();
|
|
2265
|
-
const [events, previousEvents] = await Promise.all([
|
|
2319
|
+
const [events, previousEvents, submissionRows, formSlugs] = await Promise.all([
|
|
2266
2320
|
fetchEvents(from, to),
|
|
2267
|
-
fetchEvents(previousFrom, from)
|
|
2321
|
+
fetchEvents(previousFrom, from),
|
|
2322
|
+
fetchFormSubmissions(previousFrom, to),
|
|
2323
|
+
fetchFormSlugs()
|
|
2268
2324
|
]);
|
|
2269
|
-
|
|
2325
|
+
const submissions = submissionRows?.map((submission) => ({
|
|
2326
|
+
form: formSlugs.get(String(submission.form_id)) || String(submission.form_id),
|
|
2327
|
+
session_key: String(submission.session_key || ""),
|
|
2328
|
+
created_at: String(submission.created_at)
|
|
2329
|
+
}));
|
|
2330
|
+
const currentSubmissions = submissions?.filter(
|
|
2331
|
+
(submission) => Date.parse(submission.created_at) >= fromMs
|
|
2332
|
+
);
|
|
2333
|
+
const previousSubmissions = submissions?.filter(
|
|
2334
|
+
(submission) => Date.parse(submission.created_at) < fromMs
|
|
2335
|
+
);
|
|
2336
|
+
return json(
|
|
2337
|
+
aggregateAnalytics(events, previousEvents, { from, to }, currentSubmissions, previousSubmissions)
|
|
2338
|
+
);
|
|
2270
2339
|
};
|
|
2271
2340
|
const pruneEvents = async () => {
|
|
2272
2341
|
const cutoff = new Date(Date.now() - analyticsRetentionDays * 864e5).toISOString();
|
package/dist/studio/index.js
CHANGED
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
FORM_FIELD_TYPES,
|
|
5
5
|
FormRenderer,
|
|
6
6
|
getAutoReplyEmailFields
|
|
7
|
-
} from "../chunk-
|
|
7
|
+
} from "../chunk-Z52R6XR7.js";
|
|
8
|
+
import "../chunk-DPKXKG2S.js";
|
|
8
9
|
|
|
9
10
|
// src/studio/Studio.tsx
|
|
10
11
|
import { useCallback as useCallback5, useEffect as useEffect11, useMemo as useMemo5, useState as useState12 } from "react";
|
|
@@ -626,6 +627,7 @@ function AnalyticsView({ api }) {
|
|
|
626
627
|
] }),
|
|
627
628
|
/* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
|
|
628
629
|
/* @__PURE__ */ jsx3("h3", { children: "Form funnel" }),
|
|
630
|
+
/* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "A view requires the first field to remain 75% visible for 2 seconds, or direct field interaction. Legacy form loads are excluded." }),
|
|
629
631
|
data.forms.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "No form activity yet." }) : null,
|
|
630
632
|
data.forms.map((form) => /* @__PURE__ */ jsxs3("div", { className: "ost-funnel", children: [
|
|
631
633
|
/* @__PURE__ */ jsx3("strong", { children: form.form }),
|
|
@@ -634,7 +636,7 @@ function AnalyticsView({ api }) {
|
|
|
634
636
|
{
|
|
635
637
|
max: Math.max(form.views, 1),
|
|
636
638
|
rows: [
|
|
637
|
-
{ label: "
|
|
639
|
+
{ label: "Viewed form", value: form.views },
|
|
638
640
|
{ label: "Started filling", value: form.starts },
|
|
639
641
|
{ label: "Submitted", value: form.submits }
|
|
640
642
|
]
|