@orion-studios/cms 0.5.7 → 0.5.9

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.
@@ -4,10 +4,11 @@ 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
- import { useCallback as useCallback5, useEffect as useEffect11, useMemo as useMemo5, useState as useState12 } from "react";
11
+ import { useCallback as useCallback5, useEffect as useEffect11, useMemo as useMemo4, useState as useState12 } from "react";
11
12
 
12
13
  // src/studio/api.ts
13
14
  var StudioApiError = class extends Error {
@@ -121,6 +122,7 @@ function createStudioApi(options) {
121
122
  listActivity: () => call("GET", "/activity"),
122
123
  analytics: (params) => {
123
124
  const query = new URLSearchParams();
125
+ if (params?.days) query.set("days", String(params.days));
124
126
  if (params?.from) query.set("from", params.from);
125
127
  if (params?.to) query.set("to", params.to);
126
128
  const suffix = query.toString() ? `?${query.toString()}` : "";
@@ -382,7 +384,7 @@ function LoginView({ siteName, logoUrl }) {
382
384
  }
383
385
 
384
386
  // src/studio/views/AnalyticsView.tsx
385
- import { useEffect as useEffect2, useMemo as useMemo2, useState as useState3 } from "react";
387
+ import { useEffect as useEffect2, useState as useState3 } from "react";
386
388
  import { Fragment, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
387
389
  var RANGES = [
388
390
  { key: "7", label: "7 days", days: 7 },
@@ -459,7 +461,8 @@ function TrendChart({ trend }) {
459
461
  }
460
462
  function BarList({
461
463
  rows,
462
- max
464
+ max,
465
+ emptyMessage = "Nothing recorded yet."
463
466
  }) {
464
467
  const top = max ?? Math.max(1, ...rows.map((row) => row.value));
465
468
  return /* @__PURE__ */ jsxs3("div", { className: "ost-barlist", children: [
@@ -474,7 +477,7 @@ function BarList({
474
477
  ] }) : null
475
478
  ] })
476
479
  ] }, row.label)),
477
- rows.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "Nothing recorded yet." }) : null
480
+ rows.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: emptyMessage }) : null
478
481
  ] });
479
482
  }
480
483
  function AnalyticsView({ api }) {
@@ -483,22 +486,22 @@ function AnalyticsView({ api }) {
483
486
  const [error, setError] = useState3("");
484
487
  useEffect2(() => {
485
488
  const range = RANGES.find((entry) => entry.key === rangeKey) ?? RANGES[1];
486
- const to = /* @__PURE__ */ new Date();
487
- const from = new Date(to.getTime() - range.days * 864e5);
489
+ let active = true;
488
490
  setData(null);
489
- api.analytics({ from: from.toISOString(), to: to.toISOString() }).then(
490
- setData,
491
- (e) => setError(e.message)
491
+ setError("");
492
+ api.analytics({ days: range.days }).then(
493
+ (summary) => {
494
+ if (active) setData(summary);
495
+ },
496
+ (e) => {
497
+ if (active) setError(e.message);
498
+ }
492
499
  );
500
+ return () => {
501
+ active = false;
502
+ };
493
503
  }, [api, rangeKey]);
494
- const localHours = useMemo2(() => {
495
- if (!data) return [];
496
- const offset = Math.round((/* @__PURE__ */ new Date()).getTimezoneOffset() / -60);
497
- return data.hours.map((_, hour) => ({
498
- hour,
499
- count: data.hours[((hour - offset) % 24 + 24) % 24]
500
- }));
501
- }, [data]);
504
+ const localHours = data?.hours.map((count, hour) => ({ hour, count })) ?? [];
502
505
  if (error) return /* @__PURE__ */ jsx3("div", { className: "ost-view", children: /* @__PURE__ */ jsx3("div", { className: "ost-error", children: error }) });
503
506
  const kpis = data?.kpis ?? null;
504
507
  const previous = data?.previous;
@@ -551,7 +554,7 @@ function AnalyticsView({ api }) {
551
554
  /* @__PURE__ */ jsx3(
552
555
  Kpi,
553
556
  {
554
- label: "Returning visitors",
557
+ label: "Repeat-day visitors",
555
558
  previous: previous ? { current: kpis.returningVisitors, prior: previous.returningVisitors } : void 0,
556
559
  value: String(kpis.returningVisitors)
557
560
  }
@@ -573,7 +576,7 @@ function AnalyticsView({ api }) {
573
576
  }
574
577
  ),
575
578
  /* @__PURE__ */ jsx3(Kpi, { label: "Pages per visitor", value: kpis.pagesPerVisitor.toFixed(1) }),
576
- /* @__PURE__ */ jsx3(Kpi, { label: "Conversion rate", value: pct(kpis.conversionRate) }),
579
+ /* @__PURE__ */ jsx3(Kpi, { label: "Attributed visit conversion rate", value: pct(kpis.conversionRate) }),
577
580
  /* @__PURE__ */ jsx3(
578
581
  Kpi,
579
582
  {
@@ -584,15 +587,17 @@ function AnalyticsView({ api }) {
584
587
  )
585
588
  ] }),
586
589
  /* @__PURE__ */ jsxs3("p", { className: "ost-muted ost-analytics-note", children: [
587
- "Returning visitors and cross-day totals use the pseudonymous cookie only after the visitor accepts analytics.",
588
- kpis.visitors > 0 ? ` ${kpis.identifiedVisitors} of ${kpis.visitors} visitors in this range were cookie-identified.` : ""
590
+ "Traffic counts cover consented tracked visits. Requests sent includes every stored submission. Repeat-day visitors use the pseudonymous cookie only after the visitor accepts analytics.",
591
+ kpis.visitors > 0 ? ` ${kpis.identifiedVisitors} of ${kpis.visitors} visitors in this range were cookie-identified.` : "",
592
+ ` ${kpis.attributedSubmits} of ${kpis.formSubmits} requests were linked to a tracked visit.`,
593
+ ` Ranges use site-local calendar days including today. Dates and hours use ${data.timeZone}.`
589
594
  ] }),
590
595
  /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
591
596
  /* @__PURE__ */ jsxs3("h3", { children: [
592
597
  "Daily visitors ",
593
598
  /* @__PURE__ */ jsxs3("span", { className: "ost-trend-key", children: [
594
599
  "\u25A0 visitors ",
595
- /* @__PURE__ */ jsx3("em", { children: "\u25A0 conversions" })
600
+ /* @__PURE__ */ jsx3("em", { children: "\u25A0 verified actions" })
596
601
  ] })
597
602
  ] }),
598
603
  /* @__PURE__ */ jsx3(TrendChart, { trend: data.trend })
@@ -606,26 +611,27 @@ function AnalyticsView({ api }) {
606
611
  rows: data.pages.slice(0, 12).map((page) => ({
607
612
  label: page.path === "/" ? "Home" : page.path,
608
613
  value: page.views,
609
- detail: page.conversions > 0 ? `\xB7 ${page.conversions} conv` : void 0
614
+ detail: page.conversions > 0 ? `\xB7 ${page.conversions} linked actions` : void 0
610
615
  }))
611
616
  }
612
617
  )
613
618
  ] }),
614
619
  /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
615
- /* @__PURE__ */ jsx3("h3", { children: "Where visitors come from" }),
620
+ /* @__PURE__ */ jsx3("h3", { children: "Where tracked visits come from" }),
616
621
  /* @__PURE__ */ jsx3(
617
622
  BarList,
618
623
  {
619
624
  rows: data.sources.map((source) => ({
620
625
  label: source.source,
621
626
  value: source.sessions,
622
- detail: source.conversions > 0 ? `\xB7 ${source.conversions} conv` : void 0
627
+ detail: source.conversions > 0 ? `\xB7 ${source.conversions} linked actions` : void 0
623
628
  }))
624
629
  }
625
630
  )
626
631
  ] }),
627
632
  /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
628
633
  /* @__PURE__ */ jsx3("h3", { children: "Form funnel" }),
634
+ /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "Each step counts unique tracked visits, not raw events. A view requires the first field to remain 75% visible for 2 seconds, or direct field interaction. Starts and submissions supply the minimum when a view event is unavailable. Legacy form loads are excluded." }),
629
635
  data.forms.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "No form activity yet." }) : null,
630
636
  data.forms.map((form) => /* @__PURE__ */ jsxs3("div", { className: "ost-funnel", children: [
631
637
  /* @__PURE__ */ jsx3("strong", { children: form.form }),
@@ -634,7 +640,7 @@ function AnalyticsView({ api }) {
634
640
  {
635
641
  max: Math.max(form.views, 1),
636
642
  rows: [
637
- { label: "Saw the form", value: form.views },
643
+ { label: "Viewed form", value: form.views },
638
644
  { label: "Started filling", value: form.starts },
639
645
  { label: "Submitted", value: form.submits }
640
646
  ]
@@ -643,7 +649,7 @@ function AnalyticsView({ api }) {
643
649
  ] }, form.form))
644
650
  ] }),
645
651
  /* @__PURE__ */ jsxs3("section", { className: "ost-card ost-analytics-section", children: [
646
- /* @__PURE__ */ jsx3("h3", { children: "Visitor locations" }),
652
+ /* @__PURE__ */ jsx3("h3", { children: "Tracked visit locations" }),
647
653
  /* @__PURE__ */ jsx3(
648
654
  BarList,
649
655
  {
@@ -676,7 +682,11 @@ function AnalyticsView({ api }) {
676
682
  ": ",
677
683
  device.sessions
678
684
  ] }, device.device)) }),
679
- /* @__PURE__ */ jsx3("h4", { className: "ost-analytics-subhead", children: "Busiest hours" }),
685
+ /* @__PURE__ */ jsxs3("h4", { className: "ost-analytics-subhead", children: [
686
+ "Busiest hours (",
687
+ data.timeZone,
688
+ ")"
689
+ ] }),
680
690
  /* @__PURE__ */ jsx3("div", { className: "ost-hours", children: localHours.map((entry) => {
681
691
  const max = Math.max(1, ...localHours.map((h) => h.count));
682
692
  return /* @__PURE__ */ jsx3(
@@ -684,14 +694,19 @@ function AnalyticsView({ api }) {
684
694
  {
685
695
  className: "ost-hour-bar",
686
696
  style: { height: `${Math.max(entry.count / max * 100, 4)}%` },
687
- title: `${entry.hour}:00 \u2014 ${entry.count} views`
697
+ title: `${entry.hour}:00, ${entry.count} views`
688
698
  },
689
699
  entry.hour
690
700
  );
691
701
  }) }),
692
702
  /* @__PURE__ */ jsx3("h4", { className: "ost-analytics-subhead", children: "Broken links (404s)" }),
693
- data.notFound.length === 0 ? /* @__PURE__ */ jsx3("p", { className: "ost-muted", children: "None \u2014 nice." }) : null,
694
- /* @__PURE__ */ jsx3(BarList, { rows: data.notFound.slice(0, 8).map((entry) => ({ label: entry.path, value: entry.count })) })
703
+ /* @__PURE__ */ jsx3(
704
+ BarList,
705
+ {
706
+ emptyMessage: "No broken links recorded.",
707
+ rows: data.notFound.slice(0, 8).map((entry) => ({ label: entry.path, value: entry.count }))
708
+ }
709
+ )
695
710
  ] })
696
711
  ] })
697
712
  ] }) : null
@@ -739,9 +754,7 @@ function DashboardView({
739
754
  useEffect3(() => {
740
755
  api.dashboard().then(setData, (e) => setError(e.message));
741
756
  if (canReadAnalytics) {
742
- const to = /* @__PURE__ */ new Date();
743
- const from = new Date(to.getTime() - 7 * 864e5);
744
- api.analytics({ from: from.toISOString(), to: to.toISOString() }).then(
757
+ api.analytics({ days: 7 }).then(
745
758
  (summary) => setWeek({
746
759
  visitors: summary.kpis.visitors,
747
760
  calls: summary.kpis.calls,
@@ -1445,7 +1458,7 @@ function FieldEditor({
1445
1458
  }
1446
1459
 
1447
1460
  // src/studio/views/PageEditor.tsx
1448
- import { useCallback as useCallback2, useEffect as useEffect6, useMemo as useMemo3, useRef, useState as useState7 } from "react";
1461
+ import { useCallback as useCallback2, useEffect as useEffect6, useMemo as useMemo2, useRef, useState as useState7 } from "react";
1449
1462
 
1450
1463
  // src/studio/inline.ts
1451
1464
  function collectInlineTargets(definition, data) {
@@ -2173,7 +2186,7 @@ function PageEditor({
2173
2186
  const selectedIndex = selectedId ? layout.findIndex((block) => block.id === selectedId) : -1;
2174
2187
  const selected = selectedIndex >= 0 ? layout[selectedIndex] : null;
2175
2188
  const selectedDefinition = selected ? registry.get(selected.type) : void 0;
2176
- const changedSincePublish = useMemo3(() => {
2189
+ const changedSincePublish = useMemo2(() => {
2177
2190
  if (!page) return false;
2178
2191
  if (page.status !== "published") return true;
2179
2192
  return dirty || title !== (page.published_title ?? "") || JSON.stringify(seo) !== JSON.stringify(page.published_seo ?? {}) || JSON.stringify(layout) !== JSON.stringify(page.published_layout ?? []);
@@ -2346,7 +2359,7 @@ function PageEditor({
2346
2359
  setDirty(false);
2347
2360
  setMessage("Version restored into draft.");
2348
2361
  };
2349
- const palette = useMemo3(() => registry.palette(), [registry]);
2362
+ const palette = useMemo2(() => registry.palette(), [registry]);
2350
2363
  const insertBlock = (type, at) => {
2351
2364
  const instance = registry.createInstance(type);
2352
2365
  const next = [...layout];
@@ -3312,7 +3325,7 @@ function UsersView({ api, meId }) {
3312
3325
  }
3313
3326
 
3314
3327
  // src/studio/views/views.tsx
3315
- import { useEffect as useEffect10, useMemo as useMemo4, useState as useState11 } from "react";
3328
+ import { useEffect as useEffect10, useMemo as useMemo3, useState as useState11 } from "react";
3316
3329
  import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
3317
3330
  var pageStatus = (page) => {
3318
3331
  if (page.publish_at) return { label: "scheduled", live: false };
@@ -3399,7 +3412,7 @@ function MediaView({
3399
3412
  const [selected, setSelected] = useState11(null);
3400
3413
  const [search, setSearch] = useState11("");
3401
3414
  const [error, setError] = useState11("");
3402
- const visible = useMemo4(() => {
3415
+ const visible = useMemo3(() => {
3403
3416
  const term = search.trim().toLowerCase();
3404
3417
  if (!term) return media;
3405
3418
  return media.filter(
@@ -3740,7 +3753,7 @@ var NAV = [
3740
3753
  ];
3741
3754
  function Studio({ registry, globals, siteName, logoUrl, supabaseUrl }) {
3742
3755
  const { session, loading, getToken, signOut } = useStudioSession();
3743
- const api = useMemo5(() => createStudioApi({ getToken }), [getToken]);
3756
+ const api = useMemo4(() => createStudioApi({ getToken }), [getToken]);
3744
3757
  useEffect11(() => {
3745
3758
  try {
3746
3759
  localStorage.setItem("orion-analytics-exclude", "1");
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.9",
4
4
  "description": "Orion CMS v2 core engine \u2014 JSONB content model on Supabase primitives",
5
5
  "type": "module",
6
6
  "exports": {
package/sql/bootstrap.sql CHANGED
@@ -1074,6 +1074,49 @@ begin
1074
1074
  end;
1075
1075
  $$;
1076
1076
 
1077
+ -- Aggregate-only visitor count for care-report sharing. The service role
1078
+ -- invokes it, and raw visitor and session identifiers never leave PostgreSQL.
1079
+ create or replace function public.cms_analytics_unique_visitors(
1080
+ p_from timestamptz,
1081
+ p_to timestamptz
1082
+ ) returns bigint
1083
+ language sql
1084
+ stable
1085
+ security invoker
1086
+ set search_path = ''
1087
+ as $$
1088
+ with session_identities as (
1089
+ select
1090
+ session_key,
1091
+ max(nullif(visitor_key, '')) as visitor_key,
1092
+ min(id) as first_event_id
1093
+ from public.cms_events
1094
+ where created_at >= p_from
1095
+ and created_at <= p_to
1096
+ group by session_key
1097
+ )
1098
+ select count(distinct coalesce(
1099
+ visitor_key,
1100
+ nullif(session_key, ''),
1101
+ 'event:' || first_event_id::text
1102
+ ))::bigint
1103
+ from session_identities;
1104
+ $$;
1105
+
1106
+ revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from public;
1107
+ do $analytics_aggregate_grants$
1108
+ begin
1109
+ if exists (select 1 from pg_roles where rolname = 'anon') then
1110
+ revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from anon;
1111
+ end if;
1112
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
1113
+ revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from authenticated;
1114
+ end if;
1115
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
1116
+ grant execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) to service_role;
1117
+ end if;
1118
+ end $analytics_aggregate_grants$;
1119
+
1077
1120
  -- 3) Row-level security -------------------------------------------------------
1078
1121
  -- Defense-in-depth. The app's API layer (service role) is the primary
1079
1122
  -- enforcement point; anonymous access is read-only and published-only.
@@ -0,0 +1,43 @@
1
+ -- Aggregate-only visitor count for care-report sharing. Raw visitor and
2
+ -- session identifiers stay inside PostgreSQL.
3
+ create or replace function public.cms_analytics_unique_visitors(
4
+ p_from timestamptz,
5
+ p_to timestamptz
6
+ ) returns bigint
7
+ language sql
8
+ stable
9
+ security invoker
10
+ set search_path = ''
11
+ as $$
12
+ with session_identities as (
13
+ select
14
+ session_key,
15
+ max(nullif(visitor_key, '')) as visitor_key,
16
+ min(id) as first_event_id
17
+ from public.cms_events
18
+ where created_at >= p_from
19
+ and created_at <= p_to
20
+ group by session_key
21
+ )
22
+ select count(distinct coalesce(
23
+ visitor_key,
24
+ nullif(session_key, ''),
25
+ 'event:' || first_event_id::text
26
+ ))::bigint
27
+ from session_identities;
28
+ $$;
29
+
30
+ revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from public;
31
+
32
+ do $analytics_aggregate_grants$
33
+ begin
34
+ if exists (select 1 from pg_roles where rolname = 'anon') then
35
+ revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from anon;
36
+ end if;
37
+ if exists (select 1 from pg_roles where rolname = 'authenticated') then
38
+ revoke execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) from authenticated;
39
+ end if;
40
+ if exists (select 1 from pg_roles where rolname = 'service_role') then
41
+ grant execute on function public.cms_analytics_unique_visitors(timestamptz, timestamptz) to service_role;
42
+ end if;
43
+ end $analytics_aggregate_grants$;