@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.
@@ -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 };
@@ -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,
@@ -0,0 +1,8 @@
1
+ 'use client';
2
+
3
+ // src/analytics/constants.ts
4
+ var ANALYTICS_READY_EVENT = "orion-analytics-ready";
5
+
6
+ export {
7
+ ANALYTICS_READY_EVENT
8
+ };
@@ -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 { useEffect, useMemo, useRef, useState } from "react";
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 startedRef = useRef(false);
295
- const funnel = (stage) => {
296
- if (preview || typeof window === "undefined") return;
297
- window.__orionTrack?.(
298
- "form",
299
- `${slug}:${stage}`
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
- if (!startedRef.current) {
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("div", { className: `${cls.field}${error ? ` ${cls.fieldError}` : ""}`, children: [
406
- type !== "checkbox" || options.length > 0 ? /* @__PURE__ */ jsxs("label", { className: cls.label, htmlFor: id, children: [
407
- label,
408
- field.required ? " *" : ""
409
- ] }) : null,
410
- control,
411
- field.help ? /* @__PURE__ */ jsx("p", { className: "ocf-help", children: field.help }) : null,
412
- error ? /* @__PURE__ */ jsx("p", { className: cls.errorText, role: "alert", children: error }) : null
413
- ] }, name);
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("div", { className: `${cls.field}${error ? ` ${cls.fieldError}` : ""}`, children: [
484
- /* @__PURE__ */ jsxs("label", { className: cls.checkbox, htmlFor: id, children: [
485
- /* @__PURE__ */ jsx(
486
- "input",
487
- {
488
- checked: value === true,
489
- id,
490
- onChange: (event) => setValue(name, event.target.checked, type),
491
- type: "checkbox"
492
- }
493
- ),
494
- label,
495
- field.required ? " *" : ""
496
- ] }),
497
- error ? /* @__PURE__ */ jsx("p", { className: cls.errorText, role: "alert", children: error }) : null
498
- ] }, name);
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("form", { className: cls.form, noValidate: true, onSubmit: submit, children: [
518
- title,
519
- intro,
520
- /* @__PURE__ */ jsx(
521
- "input",
522
- {
523
- "aria-hidden": "true",
524
- autoComplete: "off",
525
- className: "ocf-honeypot",
526
- name: HONEYPOT_FIELD_NAME,
527
- onChange: (event) => setHoneypot(event.target.value),
528
- style: { position: "absolute", left: "-9999px", height: 0, width: 0, opacity: 0 },
529
- tabIndex: -1,
530
- value: honeypot
531
- }
532
- ),
533
- steps.length > 1 ? /* @__PURE__ */ jsxs("p", { className: cls.stepTitle, children: [
534
- "Step ",
535
- stepIndex + 1,
536
- " of ",
537
- steps.length,
538
- steps[stepIndex]?.title ? ` \u2014 ${steps[stepIndex].title}` : ""
539
- ] }) : null,
540
- visibleSteps.map((step) => (step.fields || []).map(renderField)),
541
- state === "error" ? /* @__PURE__ */ jsx("p", { className: cls.formError, role: "alert", children: "The request could not be sent. Please try again." }) : null,
542
- /* @__PURE__ */ jsxs("div", { className: "ocf-actions", children: [
543
- steps.length > 1 && stepIndex > 0 ? /* @__PURE__ */ jsx("button", { className: cls.button, onClick: () => setStepIndex(stepIndex - 1), type: "button", children: "Back" }) : null,
544
- !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 })
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
  };
@@ -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 };
@@ -1,8 +1,11 @@
1
1
  'use client';
2
2
  "use client";
3
3
  import {
4
- FormRenderer
5
- } from "../chunk-ULE565KD.js";
4
+ FormRenderer,
5
+ useFormFunnel
6
+ } from "../chunk-Z52R6XR7.js";
7
+ import "../chunk-DPKXKG2S.js";
6
8
  export {
7
- FormRenderer
9
+ FormRenderer,
10
+ useFormFunnel
8
11
  };
@@ -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. A conversion requires a server-verified event, currently a form
187
- * submission recorded after the authoritative lead write succeeds.
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 };
@@ -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 isConversion = (event) => event.server_verified === true && (event.type === "click" && CONVERSION_NAMES.has(event.name) || event.type === "form" && event.name.endsWith(":submit"));
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.type === "form" && event.name.endsWith(":submit")) {
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 conversions = events.filter(isConversion).length;
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 kpis = computeKpis(events);
92
- const previous = computeKpis(previousEvents);
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 events) {
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 events) {
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 events) {
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.values()) {
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 (list.some(isConversion)) entry.conversions += 1;
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 events) {
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.values()) {
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 (list.some(isConversion)) entry.converted += 1;
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 events) {
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 === "view") entry.views += 1;
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 events) {
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
- return json(aggregateAnalytics(events, previousEvents, { from, to }));
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();
@@ -4,7 +4,8 @@ import {
4
4
  FORM_FIELD_TYPES,
5
5
  FormRenderer,
6
6
  getAutoReplyEmailFields
7
- } from "../chunk-ULE565KD.js";
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: "Saw the form", value: form.views },
639
+ { label: "Viewed form", value: form.views },
638
640
  { label: "Started filling", value: form.starts },
639
641
  { label: "Submitted", value: form.submits }
640
642
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orion-studios/cms",
3
- "version": "0.5.7",
3
+ "version": "0.5.8",
4
4
  "description": "Orion CMS v2 core engine \u2014 JSONB content model on Supabase primitives",
5
5
  "type": "module",
6
6
  "exports": {