@growth-labs/cms 0.5.24 → 0.5.26
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/README.md +1 -1
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +5 -2
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/options.d.ts +5 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +7 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/integration/vite-plugin.d.ts +2 -0
- package/dist/integration/vite-plugin.d.ts.map +1 -1
- package/dist/integration/vite-plugin.js +35 -0
- package/dist/integration/vite-plugin.js.map +1 -1
- package/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +8 -0
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +26 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/ui/editor/boundary-marks.d.ts +10 -0
- package/dist/ui/editor/boundary-marks.d.ts.map +1 -0
- package/dist/ui/editor/boundary-marks.js +94 -0
- package/dist/ui/editor/boundary-marks.js.map +1 -0
- package/dist/ui/editor/extensions.d.ts +1 -1
- package/dist/ui/editor/portable-text.js +1 -1
- package/dist/ui/editor/portable-text.js.map +1 -1
- package/dist/ui/editor/serialize.js +1 -1
- package/dist/ui/editor/serialize.js.map +1 -1
- package/dist/ui/editor/trim-boundary-marks.d.ts +3 -8
- package/dist/ui/editor/trim-boundary-marks.d.ts.map +1 -1
- package/dist/ui/editor/trim-boundary-marks.js +4 -93
- package/dist/ui/editor/trim-boundary-marks.js.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts +7 -0
- package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.js +38 -9
- package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
- package/dist/ui/screens/analytics-data.d.ts.map +1 -1
- package/dist/ui/screens/analytics-data.js +16 -1
- package/dist/ui/screens/analytics-data.js.map +1 -1
- package/package.json +1 -1
- package/src/integration/index.ts +4 -2
- package/src/integration/options.ts +7 -0
- package/src/integration/vite-plugin.ts +33 -0
- package/src/providers/null.ts +8 -0
- package/src/providers/types.ts +27 -1
- package/src/ui/editor/boundary-marks.ts +116 -0
- package/src/ui/editor/portable-text.ts +1 -1
- package/src/ui/editor/serialize.ts +1 -1
- package/src/ui/editor/trim-boundary-marks.ts +5 -110
- package/src/ui/screens/AnalyticsScreen.tsx +86 -7
- package/src/ui/screens/analytics-data.ts +16 -2
|
@@ -41,6 +41,13 @@ export const cmsIntegrationOptionsSchema = z
|
|
|
41
41
|
activeWorkspaceId: z.string().optional(),
|
|
42
42
|
adminBasePath: z.string().default('/admin'),
|
|
43
43
|
searchEnabled: z.boolean().default(true),
|
|
44
|
+
// Drops @puckeditor/core's lazily imported RichTextRender chunk (tiptap +
|
|
45
|
+
// prosemirror, ~176KB gzip) from SSR/worker bundles. Safe because the
|
|
46
|
+
// createPuckConfig component library never declares a `richtext` field —
|
|
47
|
+
// RichText blocks store Portable Text and render via renderRichTextHtml.
|
|
48
|
+
// Disable only if a host site adds its own `richtext`-typed Puck field to
|
|
49
|
+
// a server-rendered config.
|
|
50
|
+
stubPuckSsrRichtext: z.boolean().default(true),
|
|
44
51
|
theme: themeSchema,
|
|
45
52
|
socialSharing: socialSharingSchema,
|
|
46
53
|
surveyResults: surveyResultsSchema.optional(),
|
|
@@ -4,6 +4,39 @@ import type { ResolvedCmsIntegrationOptions } from './options.js'
|
|
|
4
4
|
const VIRTUAL_MODULE_ID = 'virtual:growth-labs/cms/config'
|
|
5
5
|
const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`
|
|
6
6
|
|
|
7
|
+
export const PUCK_SSR_RICHTEXT_STUB_ID = '\0growth-labs/cms/puck-ssr-richtext-stub'
|
|
8
|
+
|
|
9
|
+
// @puckeditor/core's rsc entry wires `lazy(() => import('./Render-<hash>.mjs'))`
|
|
10
|
+
// for `richtext`-typed fields, so every SSR bundle that renders <Render> ships
|
|
11
|
+
// the whole tiptap/prosemirror editor even though createPuckConfig never
|
|
12
|
+
// declares such a field (RichText blocks store Portable Text; see
|
|
13
|
+
// src/puck/config.tsx). Stubbing the dynamic import at resolve time lets
|
|
14
|
+
// Rollup drop that dead subtree from worker bundles. Client builds are left
|
|
15
|
+
// alone: the admin editor legitimately code-splits the same chunk.
|
|
16
|
+
export function growthLabsPuckSsrRichtextStubPlugin(): Plugin {
|
|
17
|
+
return {
|
|
18
|
+
name: 'growth-labs-cms-puck-ssr-richtext-stub',
|
|
19
|
+
enforce: 'pre',
|
|
20
|
+
resolveId(source, importer, options) {
|
|
21
|
+
if (!options?.ssr) return undefined
|
|
22
|
+
if (!importer || !/@puckeditor[\\/]core[\\/]dist[\\/]/.test(importer)) return undefined
|
|
23
|
+
if (!/^\.\/Render-[\w-]+\.mjs$/.test(source)) return undefined
|
|
24
|
+
return PUCK_SSR_RICHTEXT_STUB_ID
|
|
25
|
+
},
|
|
26
|
+
load(id) {
|
|
27
|
+
if (id !== PUCK_SSR_RICHTEXT_STUB_ID) return undefined
|
|
28
|
+
// A loud marker, same convention as data-pt-unknown / gl-layout-unknown:
|
|
29
|
+
// if a host ever server-renders a real `richtext` field with the stub
|
|
30
|
+
// active, the gap is visible and greppable instead of silent.
|
|
31
|
+
return [
|
|
32
|
+
"import { jsx } from 'react/jsx-runtime'",
|
|
33
|
+
'export const RichTextRender = () =>',
|
|
34
|
+
"\tjsx('div', { 'data-gl-layout-unknown': 'puck-richtext-ssr-stubbed' })",
|
|
35
|
+
].join('\n')
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
7
40
|
export function growthLabsCmsPlugin(config: ResolvedCmsIntegrationOptions): Plugin {
|
|
8
41
|
const needsTransform = config.adminBasePath !== '/admin'
|
|
9
42
|
const replacePath = `${config.adminBasePath}/api`
|
package/src/providers/null.ts
CHANGED
|
@@ -59,6 +59,14 @@ export const nullAnalytics: AnalyticsProvider = {
|
|
|
59
59
|
},
|
|
60
60
|
readsTimeSeries: [],
|
|
61
61
|
sources: [],
|
|
62
|
+
funnel: {
|
|
63
|
+
views: null,
|
|
64
|
+
uniqueVisitors: null,
|
|
65
|
+
readCompletionRate: null,
|
|
66
|
+
paywallShown: null,
|
|
67
|
+
subscribeClicks: null,
|
|
68
|
+
boundaryConversionRate: null,
|
|
69
|
+
},
|
|
62
70
|
})
|
|
63
71
|
},
|
|
64
72
|
}
|
package/src/providers/types.ts
CHANGED
|
@@ -5,6 +5,13 @@ export type ProviderStatus = 'live' | 'partial' | 'empty'
|
|
|
5
5
|
export interface ProviderResult<T> {
|
|
6
6
|
status: ProviderStatus
|
|
7
7
|
data: T
|
|
8
|
+
/**
|
|
9
|
+
* Optional per-key data lineage: where each number in `data` comes from
|
|
10
|
+
* (e.g. `{ subscriptionsStarted: 'billing (site D1)', reads: 'rollup' }`).
|
|
11
|
+
* The UI shows these so a reader never has to guess whether a figure is
|
|
12
|
+
* ground truth or a derived rollup.
|
|
13
|
+
*/
|
|
14
|
+
sources?: Record<string, string>
|
|
8
15
|
}
|
|
9
16
|
|
|
10
17
|
export interface DateRange {
|
|
@@ -23,6 +30,24 @@ export interface ArticleAnalyticsSource {
|
|
|
23
30
|
paidStarts: number
|
|
24
31
|
}
|
|
25
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Per-article free/premium boundary funnel. Every stage is `number | null`:
|
|
35
|
+
* null means NOT MEASURED for this article/range (event not instrumented,
|
|
36
|
+
* history predates slug attribution, or the fact table has no rows) and the
|
|
37
|
+
* UI must say so — never render a silent zero.
|
|
38
|
+
*/
|
|
39
|
+
export interface ArticleFunnelStages {
|
|
40
|
+
views: number | null
|
|
41
|
+
uniqueVisitors: number | null
|
|
42
|
+
/** 0..1 fraction of views that reached the 90% scroll mark. */
|
|
43
|
+
readCompletionRate: number | null
|
|
44
|
+
paywallShown: number | null
|
|
45
|
+
/** Boundary clicks: paywall CTA + inline membership CTA, per article. */
|
|
46
|
+
subscribeClicks: number | null
|
|
47
|
+
/** subscribeClicks / paywallShown (or the host's documented equivalent). */
|
|
48
|
+
boundaryConversionRate: number | null
|
|
49
|
+
}
|
|
50
|
+
|
|
26
51
|
export interface ArticleAnalyticsDetail {
|
|
27
52
|
id: string
|
|
28
53
|
slug: string
|
|
@@ -61,10 +86,11 @@ export interface ArticleAnalyticsDetail {
|
|
|
61
86
|
}
|
|
62
87
|
readsTimeSeries: Array<{ t: number; reads: number }>
|
|
63
88
|
sources: ArticleAnalyticsSource[]
|
|
89
|
+
funnel?: ArticleFunnelStages
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
export interface AnalyticsProvider {
|
|
67
|
-
getKpis(range: DateRange): Promise<ProviderResult<Record<string, number>>>
|
|
93
|
+
getKpis(range: DateRange): Promise<ProviderResult<Record<string, number | null>>>
|
|
68
94
|
getReadsTimeSeries(range: DateRange): Promise<ProviderResult<Array<{ t: number; reads: number }>>>
|
|
69
95
|
getTopContent(
|
|
70
96
|
range: DateRange,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Pure JSONContent boundary-mark normalization. Split from
|
|
2
|
+
// trim-boundary-marks.ts so server-safe consumers (serialize.ts,
|
|
3
|
+
// portable-text.ts, and through them the @growth-labs/cms/puck subpath) can
|
|
4
|
+
// use it without dragging the Tiptap/ProseMirror runtime into SSR bundles —
|
|
5
|
+
// the live-editor Extension stays in trim-boundary-marks.ts.
|
|
6
|
+
import type { JSONContent } from '@tiptap/core'
|
|
7
|
+
|
|
8
|
+
export const BOUNDARY_MARKS = ['link'] as const
|
|
9
|
+
|
|
10
|
+
function markKey(mark: NonNullable<JSONContent['marks']>[number]): string {
|
|
11
|
+
return `${mark.type}:${JSON.stringify(mark.attrs ?? {})}`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sameMarks(
|
|
15
|
+
a: JSONContent['marks'] | undefined,
|
|
16
|
+
b: JSONContent['marks'] | undefined,
|
|
17
|
+
): boolean {
|
|
18
|
+
return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mergeAdjacentText(nodes: JSONContent[]): JSONContent[] {
|
|
22
|
+
const merged: JSONContent[] = []
|
|
23
|
+
for (const node of nodes) {
|
|
24
|
+
const previous = merged.at(-1)
|
|
25
|
+
if (
|
|
26
|
+
previous?.type === 'text' &&
|
|
27
|
+
node.type === 'text' &&
|
|
28
|
+
sameMarks(previous.marks, node.marks)
|
|
29
|
+
) {
|
|
30
|
+
previous.text = (previous.text ?? '') + (node.text ?? '')
|
|
31
|
+
} else {
|
|
32
|
+
merged.push(node)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return merged
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function trimJsonMark(nodes: JSONContent[], markType: (typeof BOUNDARY_MARKS)[number]) {
|
|
39
|
+
const output: JSONContent[] = []
|
|
40
|
+
let index = 0
|
|
41
|
+
|
|
42
|
+
while (index < nodes.length) {
|
|
43
|
+
const node = nodes[index]
|
|
44
|
+
const mark =
|
|
45
|
+
node.type === 'text'
|
|
46
|
+
? node.marks?.find((candidate) => candidate.type === markType)
|
|
47
|
+
: undefined
|
|
48
|
+
if (!mark) {
|
|
49
|
+
output.push(node)
|
|
50
|
+
index += 1
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const key = markKey(mark)
|
|
55
|
+
const run: JSONContent[] = []
|
|
56
|
+
while (index < nodes.length) {
|
|
57
|
+
const candidate = nodes[index]
|
|
58
|
+
const candidateMark =
|
|
59
|
+
candidate.type === 'text'
|
|
60
|
+
? candidate.marks?.find((item) => item.type === markType && markKey(item) === key)
|
|
61
|
+
: undefined
|
|
62
|
+
if (!candidateMark) break
|
|
63
|
+
run.push(candidate)
|
|
64
|
+
index += 1
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const text = run.map((item) => item.text ?? '').join('')
|
|
68
|
+
const leading = text.match(/^\s+/u)?.[0].length ?? 0
|
|
69
|
+
const trailing = text.match(/\s+$/u)?.[0].length ?? 0
|
|
70
|
+
const markedEnd = Math.max(leading, text.length - trailing)
|
|
71
|
+
let offset = 0
|
|
72
|
+
|
|
73
|
+
for (const item of run) {
|
|
74
|
+
const itemText = item.text ?? ''
|
|
75
|
+
const itemStart = offset
|
|
76
|
+
const itemEnd = offset + itemText.length
|
|
77
|
+
const cuts = [itemStart, Math.max(itemStart, leading), Math.min(itemEnd, markedEnd), itemEnd]
|
|
78
|
+
.filter(
|
|
79
|
+
(value, cutIndex, all) =>
|
|
80
|
+
value >= itemStart && value <= itemEnd && all.indexOf(value) === cutIndex,
|
|
81
|
+
)
|
|
82
|
+
.sort((a, b) => a - b)
|
|
83
|
+
|
|
84
|
+
for (let cutIndex = 0; cutIndex < cuts.length - 1; cutIndex += 1) {
|
|
85
|
+
const from = cuts[cutIndex]
|
|
86
|
+
const to = cuts[cutIndex + 1]
|
|
87
|
+
if (from === to) continue
|
|
88
|
+
const keepMark = from >= leading && to <= markedEnd
|
|
89
|
+
const marks = keepMark
|
|
90
|
+
? item.marks
|
|
91
|
+
: item.marks?.filter((candidate) => candidate.type !== markType)
|
|
92
|
+
output.push({
|
|
93
|
+
...item,
|
|
94
|
+
text: itemText.slice(from - itemStart, to - itemStart),
|
|
95
|
+
...(marks?.length ? { marks } : { marks: undefined }),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
offset = itemEnd
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return mergeAdjacentText(output)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Remove link and underline marks from leading/trailing whitespace in every
|
|
107
|
+
* inline mark run. Underline follows the same rule as links: whitespace is
|
|
108
|
+
* layout, not editorial emphasis, and pasted underline is the visible half of
|
|
109
|
+
* the malformed-link report.
|
|
110
|
+
*/
|
|
111
|
+
export function normalizeBoundaryMarks(node: JSONContent): JSONContent {
|
|
112
|
+
if (!node.content) return node
|
|
113
|
+
let content = node.content.map(normalizeBoundaryMarks)
|
|
114
|
+
for (const markType of BOUNDARY_MARKS) content = trimJsonMark(content, markType)
|
|
115
|
+
return { ...node, content }
|
|
116
|
+
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import type { JSONContent } from '@tiptap/core'
|
|
21
21
|
import type { PortableTextObject } from '../../schema/portable-text.js'
|
|
22
|
-
import { normalizeBoundaryMarks } from './
|
|
22
|
+
import { normalizeBoundaryMarks } from './boundary-marks.js'
|
|
23
23
|
|
|
24
24
|
type KeyGenerator = () => string
|
|
25
25
|
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
// (inner markdown re-serializes byte-identically)
|
|
30
30
|
|
|
31
31
|
import type { JSONContent } from '@tiptap/core'
|
|
32
|
-
import { normalizeBoundaryMarks } from './
|
|
32
|
+
import { normalizeBoundaryMarks } from './boundary-marks.js'
|
|
33
33
|
|
|
34
34
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
35
|
// docToMarkdown — JSONContent → string
|
|
@@ -1,116 +1,11 @@
|
|
|
1
|
-
import { Extension
|
|
1
|
+
import { Extension } from '@tiptap/core'
|
|
2
2
|
import type { Mark, MarkType, Node as ProseMirrorNode } from '@tiptap/pm/model'
|
|
3
3
|
import { Plugin } from '@tiptap/pm/state'
|
|
4
|
+
import { BOUNDARY_MARKS, normalizeBoundaryMarks } from './boundary-marks.js'
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return `${mark.type}:${JSON.stringify(mark.attrs ?? {})}`
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function sameMarks(
|
|
12
|
-
a: JSONContent['marks'] | undefined,
|
|
13
|
-
b: JSONContent['marks'] | undefined,
|
|
14
|
-
): boolean {
|
|
15
|
-
return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function mergeAdjacentText(nodes: JSONContent[]): JSONContent[] {
|
|
19
|
-
const merged: JSONContent[] = []
|
|
20
|
-
for (const node of nodes) {
|
|
21
|
-
const previous = merged.at(-1)
|
|
22
|
-
if (
|
|
23
|
-
previous?.type === 'text' &&
|
|
24
|
-
node.type === 'text' &&
|
|
25
|
-
sameMarks(previous.marks, node.marks)
|
|
26
|
-
) {
|
|
27
|
-
previous.text = (previous.text ?? '') + (node.text ?? '')
|
|
28
|
-
} else {
|
|
29
|
-
merged.push(node)
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
return merged
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function trimJsonMark(nodes: JSONContent[], markType: (typeof BOUNDARY_MARKS)[number]) {
|
|
36
|
-
const output: JSONContent[] = []
|
|
37
|
-
let index = 0
|
|
38
|
-
|
|
39
|
-
while (index < nodes.length) {
|
|
40
|
-
const node = nodes[index]
|
|
41
|
-
const mark =
|
|
42
|
-
node.type === 'text'
|
|
43
|
-
? node.marks?.find((candidate) => candidate.type === markType)
|
|
44
|
-
: undefined
|
|
45
|
-
if (!mark) {
|
|
46
|
-
output.push(node)
|
|
47
|
-
index += 1
|
|
48
|
-
continue
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const key = markKey(mark)
|
|
52
|
-
const run: JSONContent[] = []
|
|
53
|
-
while (index < nodes.length) {
|
|
54
|
-
const candidate = nodes[index]
|
|
55
|
-
const candidateMark =
|
|
56
|
-
candidate.type === 'text'
|
|
57
|
-
? candidate.marks?.find((item) => item.type === markType && markKey(item) === key)
|
|
58
|
-
: undefined
|
|
59
|
-
if (!candidateMark) break
|
|
60
|
-
run.push(candidate)
|
|
61
|
-
index += 1
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const text = run.map((item) => item.text ?? '').join('')
|
|
65
|
-
const leading = text.match(/^\s+/u)?.[0].length ?? 0
|
|
66
|
-
const trailing = text.match(/\s+$/u)?.[0].length ?? 0
|
|
67
|
-
const markedEnd = Math.max(leading, text.length - trailing)
|
|
68
|
-
let offset = 0
|
|
69
|
-
|
|
70
|
-
for (const item of run) {
|
|
71
|
-
const itemText = item.text ?? ''
|
|
72
|
-
const itemStart = offset
|
|
73
|
-
const itemEnd = offset + itemText.length
|
|
74
|
-
const cuts = [itemStart, Math.max(itemStart, leading), Math.min(itemEnd, markedEnd), itemEnd]
|
|
75
|
-
.filter(
|
|
76
|
-
(value, cutIndex, all) =>
|
|
77
|
-
value >= itemStart && value <= itemEnd && all.indexOf(value) === cutIndex,
|
|
78
|
-
)
|
|
79
|
-
.sort((a, b) => a - b)
|
|
80
|
-
|
|
81
|
-
for (let cutIndex = 0; cutIndex < cuts.length - 1; cutIndex += 1) {
|
|
82
|
-
const from = cuts[cutIndex]
|
|
83
|
-
const to = cuts[cutIndex + 1]
|
|
84
|
-
if (from === to) continue
|
|
85
|
-
const keepMark = from >= leading && to <= markedEnd
|
|
86
|
-
const marks = keepMark
|
|
87
|
-
? item.marks
|
|
88
|
-
: item.marks?.filter((candidate) => candidate.type !== markType)
|
|
89
|
-
output.push({
|
|
90
|
-
...item,
|
|
91
|
-
text: itemText.slice(from - itemStart, to - itemStart),
|
|
92
|
-
...(marks?.length ? { marks } : { marks: undefined }),
|
|
93
|
-
})
|
|
94
|
-
}
|
|
95
|
-
offset = itemEnd
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return mergeAdjacentText(output)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Remove link and underline marks from leading/trailing whitespace in every
|
|
104
|
-
* inline mark run. Underline follows the same rule as links: whitespace is
|
|
105
|
-
* layout, not editorial emphasis, and pasted underline is the visible half of
|
|
106
|
-
* the malformed-link report.
|
|
107
|
-
*/
|
|
108
|
-
export function normalizeBoundaryMarks(node: JSONContent): JSONContent {
|
|
109
|
-
if (!node.content) return node
|
|
110
|
-
let content = node.content.map(normalizeBoundaryMarks)
|
|
111
|
-
for (const markType of BOUNDARY_MARKS) content = trimJsonMark(content, markType)
|
|
112
|
-
return { ...node, content }
|
|
113
|
-
}
|
|
6
|
+
// Server-safe callers import normalizeBoundaryMarks from ./boundary-marks.js
|
|
7
|
+
// directly; this re-export keeps the historical editor-side import path alive.
|
|
8
|
+
export { normalizeBoundaryMarks }
|
|
114
9
|
|
|
115
10
|
function matchingMark(
|
|
116
11
|
node: ProseMirrorNode,
|
|
@@ -21,7 +21,11 @@
|
|
|
21
21
|
// a .card-title + .masthead-rule hairline. NO BData, NO mock numbers.
|
|
22
22
|
|
|
23
23
|
import { useCallback, useEffect, useReducer, useState } from 'react'
|
|
24
|
-
import type {
|
|
24
|
+
import type {
|
|
25
|
+
ArticleAnalyticsDetail,
|
|
26
|
+
ArticleFunnelStages,
|
|
27
|
+
ProviderResult,
|
|
28
|
+
} from '../../providers/types.js'
|
|
25
29
|
import { Icon } from '../icons.js'
|
|
26
30
|
import {
|
|
27
31
|
type AnalyticsData,
|
|
@@ -358,6 +362,8 @@ function ArticleAnalyticsDetailBody({
|
|
|
358
362
|
/>
|
|
359
363
|
</div>
|
|
360
364
|
|
|
365
|
+
<ArticleFunnelPanel funnel={detail.funnel} />
|
|
366
|
+
|
|
361
367
|
<div className="grid-split-wide">
|
|
362
368
|
<ReadsChart series={readsResult} />
|
|
363
369
|
<ScrollDepthPanel detail={detail} />
|
|
@@ -368,6 +374,63 @@ function ArticleAnalyticsDetailBody({
|
|
|
368
374
|
)
|
|
369
375
|
}
|
|
370
376
|
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// ArticleFunnelPanel — the free/premium boundary, per article
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Each stage is number | null; null renders an explicit "not measured", never
|
|
381
|
+
// a silent zero (unmeasured history and uninstrumented events must look
|
|
382
|
+
// different from a genuinely quiet article).
|
|
383
|
+
|
|
384
|
+
const FUNNEL_STAGE_DEFS: Array<{
|
|
385
|
+
key: keyof ArticleFunnelStages & string
|
|
386
|
+
label: string
|
|
387
|
+
fmt: 'num' | 'pct'
|
|
388
|
+
}> = [
|
|
389
|
+
{ key: 'views', label: 'Views', fmt: 'num' },
|
|
390
|
+
{ key: 'uniqueVisitors', label: 'Unique visitors', fmt: 'num' },
|
|
391
|
+
{ key: 'readCompletionRate', label: 'Read completion', fmt: 'pct' },
|
|
392
|
+
{ key: 'paywallShown', label: 'Boundary shown', fmt: 'num' },
|
|
393
|
+
{ key: 'subscribeClicks', label: 'Boundary clicks', fmt: 'num' },
|
|
394
|
+
{ key: 'boundaryConversionRate', label: 'Boundary conversion', fmt: 'pct' },
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
export function ArticleFunnelPanel({ funnel }: { funnel: ArticleFunnelStages | undefined }) {
|
|
398
|
+
return (
|
|
399
|
+
<div className="panel panel-pad">
|
|
400
|
+
<div className="kicker" style={{ marginBottom: 10 }}>
|
|
401
|
+
Conversion funnel
|
|
402
|
+
</div>
|
|
403
|
+
<div className="grid-stats">
|
|
404
|
+
{FUNNEL_STAGE_DEFS.map((stage) => {
|
|
405
|
+
const raw = funnel?.[stage.key] ?? null
|
|
406
|
+
const measured = typeof raw === 'number' && Number.isFinite(raw)
|
|
407
|
+
return (
|
|
408
|
+
<div key={stage.key}>
|
|
409
|
+
<div style={{ fontSize: 11, color: 'var(--ink-muted)' }}>{stage.label}</div>
|
|
410
|
+
<div
|
|
411
|
+
className="serif tnum"
|
|
412
|
+
style={{ fontSize: 22, fontWeight: 600, color: 'var(--ink)', margin: '4px 0 2px' }}
|
|
413
|
+
>
|
|
414
|
+
{measured ? (
|
|
415
|
+
stage.fmt === 'pct' ? (
|
|
416
|
+
formatPercent(raw)
|
|
417
|
+
) : (
|
|
418
|
+
formatNumber(raw)
|
|
419
|
+
)
|
|
420
|
+
) : (
|
|
421
|
+
<span style={{ fontSize: 13, color: 'var(--ink-faint)', fontStyle: 'italic' }}>
|
|
422
|
+
not measured
|
|
423
|
+
</span>
|
|
424
|
+
)}
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
)
|
|
428
|
+
})}
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
|
|
371
434
|
function ScrollDepthPanel({ detail }: { detail: ArticleAnalyticsDetail }) {
|
|
372
435
|
const rows = [
|
|
373
436
|
{ label: '25%', count: detail.scrollDepth.scroll25, rate: detail.scrollDepth.scroll25Rate },
|
|
@@ -520,7 +583,7 @@ function AnalyticsBody({ data, isPartial }: { data: AnalyticsData; isPartial: bo
|
|
|
520
583
|
return (
|
|
521
584
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
|
|
522
585
|
{isPartial && (
|
|
523
|
-
<PartialBanner message="
|
|
586
|
+
<PartialBanner message="Some metrics are unavailable for this range — each tile shows its own state and source." />
|
|
524
587
|
)}
|
|
525
588
|
|
|
526
589
|
{/* KPI cards */}
|
|
@@ -561,6 +624,12 @@ const KPI_DEFS: Array<{
|
|
|
561
624
|
fmt: 'num',
|
|
562
625
|
emptyNote: 'No visitor data',
|
|
563
626
|
},
|
|
627
|
+
{
|
|
628
|
+
key: 'subscriptionsStarted',
|
|
629
|
+
label: 'New subscriptions',
|
|
630
|
+
fmt: 'num',
|
|
631
|
+
emptyNote: 'No subscription data',
|
|
632
|
+
},
|
|
564
633
|
{
|
|
565
634
|
key: 'conversionRate',
|
|
566
635
|
label: 'Conversion',
|
|
@@ -569,7 +638,7 @@ const KPI_DEFS: Array<{
|
|
|
569
638
|
},
|
|
570
639
|
]
|
|
571
640
|
|
|
572
|
-
function KpiCards({ kpis }: { kpis: ProviderResult<Record<string, number | null>> }) {
|
|
641
|
+
export function KpiCards({ kpis }: { kpis: ProviderResult<Record<string, number | null>> }) {
|
|
573
642
|
const kpiStatus = selectKpiState(kpis)
|
|
574
643
|
|
|
575
644
|
if (kpiStatus === 'empty') {
|
|
@@ -586,19 +655,22 @@ function KpiCards({ kpis }: { kpis: ProviderResult<Record<string, number | null>
|
|
|
586
655
|
{KPI_DEFS.map((def) => {
|
|
587
656
|
const raw = kpis.data?.[def.key] ?? null
|
|
588
657
|
const formatted = formatKpi(raw, def.fmt)
|
|
589
|
-
const
|
|
658
|
+
const isUnmeasured =
|
|
590
659
|
formatted !== null && typeof formatted === 'object' && (formatted as KpiPending).pending
|
|
591
660
|
|
|
592
|
-
const displayValue =
|
|
593
|
-
|
|
661
|
+
const displayValue = isUnmeasured ? null : (formatted as string)
|
|
662
|
+
// A null KPI is an honest gap — the provider could not measure it
|
|
663
|
+
// for this range — not a promise that a backfill will fill it.
|
|
664
|
+
const displayNote = isUnmeasured ? 'not measured' : null
|
|
594
665
|
|
|
595
666
|
return (
|
|
596
667
|
<KpiCard
|
|
597
668
|
key={def.key}
|
|
598
669
|
label={def.label}
|
|
599
|
-
status={
|
|
670
|
+
status={isUnmeasured ? 'partial' : kpiStatus}
|
|
600
671
|
value={displayValue}
|
|
601
672
|
note={displayNote}
|
|
673
|
+
source={kpis.sources?.[def.key] ?? null}
|
|
602
674
|
/>
|
|
603
675
|
)
|
|
604
676
|
})}
|
|
@@ -611,11 +683,13 @@ function KpiCard({
|
|
|
611
683
|
status,
|
|
612
684
|
value,
|
|
613
685
|
note,
|
|
686
|
+
source,
|
|
614
687
|
}: {
|
|
615
688
|
label: string
|
|
616
689
|
status: 'live' | 'partial' | 'empty'
|
|
617
690
|
value: string | null
|
|
618
691
|
note: string | null
|
|
692
|
+
source?: string | null
|
|
619
693
|
}) {
|
|
620
694
|
const empty = status === 'empty' || value === null
|
|
621
695
|
return (
|
|
@@ -631,6 +705,11 @@ function KpiCard({
|
|
|
631
705
|
{status === 'partial' && !note && (
|
|
632
706
|
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--amber)' }}>partial</span>
|
|
633
707
|
)}
|
|
708
|
+
{source && (
|
|
709
|
+
<div style={{ fontSize: 10, color: 'var(--ink-faint)', marginTop: 4 }} title="Data source">
|
|
710
|
+
{source}
|
|
711
|
+
</div>
|
|
712
|
+
)}
|
|
634
713
|
</div>
|
|
635
714
|
)
|
|
636
715
|
}
|
|
@@ -24,14 +24,28 @@ function providerStatus(value: unknown): ProviderStatus {
|
|
|
24
24
|
return value === 'live' || value === 'partial' || value === 'empty' ? value : 'empty'
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
function normalizeSources(value: unknown): Record<string, string> | undefined {
|
|
28
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
29
|
+
const out: Record<string, string> = {}
|
|
30
|
+
for (const [key, label] of Object.entries(value as Record<string, unknown>)) {
|
|
31
|
+
if (typeof label === 'string' && label.length > 0) out[key] = label
|
|
32
|
+
}
|
|
33
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
function normalizeObjectResult(result: unknown): ProviderResult<Record<string, number | null>> {
|
|
28
37
|
if (!result || typeof result !== 'object') return { status: 'empty', data: {} }
|
|
29
|
-
const record = result as { status?: unknown; data?: unknown }
|
|
38
|
+
const record = result as { status?: unknown; data?: unknown; sources?: unknown }
|
|
30
39
|
const data =
|
|
31
40
|
record.data && typeof record.data === 'object' && !Array.isArray(record.data)
|
|
32
41
|
? (record.data as Record<string, number | null>)
|
|
33
42
|
: {}
|
|
34
|
-
|
|
43
|
+
const sources = normalizeSources(record.sources)
|
|
44
|
+
return {
|
|
45
|
+
status: providerStatus(record.status),
|
|
46
|
+
data,
|
|
47
|
+
...(sources ? { sources } : {}),
|
|
48
|
+
}
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
function normalizeArrayResult<T>(result: unknown): ProviderResult<T[]> {
|