@growth-labs/cms 0.5.5 → 0.5.7
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 +46 -0
- package/dist/engine/ai-dek-notifications.d.ts +16 -0
- package/dist/engine/ai-dek-notifications.d.ts.map +1 -0
- package/dist/engine/ai-dek-notifications.js +34 -0
- package/dist/engine/ai-dek-notifications.js.map +1 -0
- package/dist/engine/ai-dek.d.ts +35 -0
- package/dist/engine/ai-dek.d.ts.map +1 -0
- package/dist/engine/ai-dek.js +99 -0
- package/dist/engine/ai-dek.js.map +1 -0
- package/dist/engine/ai-prompts.d.ts +12 -0
- package/dist/engine/ai-prompts.d.ts.map +1 -1
- package/dist/engine/ai-prompts.js +17 -0
- package/dist/engine/ai-prompts.js.map +1 -1
- package/dist/engine/index.d.ts +3 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +4 -1
- package/dist/engine/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +13 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/options.d.ts +19 -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/providers/null.d.ts +2 -1
- package/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +9 -0
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +10 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/ai.d.ts +2 -0
- package/dist/routes/ai.d.ts.map +1 -1
- package/dist/routes/ai.js +42 -0
- package/dist/routes/ai.js.map +1 -1
- package/dist/routes/authz-matrix.d.ts +2 -1
- package/dist/routes/authz-matrix.d.ts.map +1 -1
- package/dist/routes/authz-matrix.js +5 -0
- package/dist/routes/authz-matrix.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +43 -1
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/context.d.ts +1 -1
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/surveys.d.ts +12 -0
- package/dist/routes/surveys.d.ts.map +1 -0
- package/dist/routes/surveys.js +109 -0
- package/dist/routes/surveys.js.map +1 -0
- package/dist/schema/insights-ingest.d.ts +46 -46
- package/dist/surveys/access.d.ts +4 -0
- package/dist/surveys/access.d.ts.map +1 -0
- package/dist/surveys/access.js +9 -0
- package/dist/surveys/access.js.map +1 -0
- package/dist/surveys/errors.d.ts +43 -0
- package/dist/surveys/errors.d.ts.map +1 -0
- package/dist/surveys/errors.js +42 -0
- package/dist/surveys/errors.js.map +1 -0
- package/dist/surveys/schema.d.ts +939 -0
- package/dist/surveys/schema.d.ts.map +1 -0
- package/dist/surveys/schema.js +385 -0
- package/dist/surveys/schema.js.map +1 -0
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +1 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/content/[id]/ai/dek.d.ts +3 -0
- package/dist/ui/api/content/[id]/ai/dek.d.ts.map +1 -0
- package/dist/ui/api/content/[id]/ai/dek.js +5 -0
- package/dist/ui/api/content/[id]/ai/dek.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts +3 -0
- package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/responses.csv.js +17 -0
- package/dist/ui/api/surveys/[surveyId]/responses.csv.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId].d.ts +3 -0
- package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -0
- package/dist/ui/api/surveys/[surveyId].js +17 -0
- package/dist/ui/api/surveys/[surveyId].js.map +1 -0
- package/dist/ui/client/mount.d.ts.map +1 -1
- package/dist/ui/client/mount.js +1 -0
- package/dist/ui/client/mount.js.map +1 -1
- package/dist/ui/commands.d.ts +5 -1
- package/dist/ui/commands.d.ts.map +1 -1
- package/dist/ui/commands.js +7 -0
- package/dist/ui/commands.js.map +1 -1
- package/dist/ui/components/CmsApp.d.ts +2 -0
- package/dist/ui/components/CmsApp.d.ts.map +1 -1
- package/dist/ui/components/CmsApp.js +14 -3
- package/dist/ui/components/CmsApp.js.map +1 -1
- package/dist/ui/components/CommandPalette.d.ts +2 -0
- package/dist/ui/components/CommandPalette.d.ts.map +1 -1
- package/dist/ui/components/CommandPalette.js +1 -1
- package/dist/ui/components/CommandPalette.js.map +1 -1
- package/dist/ui/components/Sidebar.d.ts +1 -0
- package/dist/ui/components/Sidebar.d.ts.map +1 -1
- package/dist/ui/components/Sidebar.js +1 -1
- package/dist/ui/components/Sidebar.js.map +1 -1
- package/dist/ui/editor/AiAssistPanel.d.ts +3 -1
- package/dist/ui/editor/AiAssistPanel.d.ts.map +1 -1
- package/dist/ui/editor/AiAssistPanel.js +28 -4
- package/dist/ui/editor/AiAssistPanel.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +1 -1
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/ai-assist.d.ts +2 -2
- package/dist/ui/editor/ai-assist.d.ts.map +1 -1
- package/dist/ui/hash-router.d.ts +1 -1
- package/dist/ui/hash-router.d.ts.map +1 -1
- package/dist/ui/hash-router.js +1 -0
- package/dist/ui/hash-router.js.map +1 -1
- package/dist/ui/nav.d.ts +5 -1
- package/dist/ui/nav.d.ts.map +1 -1
- package/dist/ui/nav.js +10 -2
- package/dist/ui/nav.js.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +28 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.js +110 -0
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -0
- package/dist/ui/screens/registry.d.ts +2 -1
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +5 -1
- package/dist/ui/screens/registry.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/ai-dek-notifications.ts +50 -0
- package/src/engine/ai-dek.ts +138 -0
- package/src/engine/ai-prompts.ts +25 -0
- package/src/engine/index.ts +14 -0
- package/src/index.ts +2 -2
- package/src/integration/index.ts +13 -0
- package/src/integration/options.ts +8 -0
- package/src/providers/null.ts +19 -0
- package/src/providers/types.ts +7 -0
- package/src/routes/ai.ts +44 -0
- package/src/routes/authz-matrix.ts +14 -1
- package/src/routes/content.ts +45 -1
- package/src/routes/context.ts +3 -0
- package/src/routes/index.ts +6 -0
- package/src/routes/surveys.ts +130 -0
- package/src/surveys/access.ts +11 -0
- package/src/surveys/errors.ts +51 -0
- package/src/surveys/schema.ts +435 -0
- package/src/ui/api/_content-config.ts +2 -0
- package/src/ui/api/content/[id]/ai/dek.ts +8 -0
- package/src/ui/api/surveys/[surveyId]/responses.csv.ts +23 -0
- package/src/ui/api/surveys/[surveyId].ts +23 -0
- package/src/ui/client/mount.tsx +1 -0
- package/src/ui/commands.ts +14 -1
- package/src/ui/components/CmsApp.tsx +25 -3
- package/src/ui/components/CommandPalette.tsx +2 -1
- package/src/ui/components/Sidebar.tsx +10 -2
- package/src/ui/editor/AiAssistPanel.tsx +55 -3
- package/src/ui/editor/ContentForm.tsx +3 -1
- package/src/ui/editor/ai-assist.ts +2 -2
- package/src/ui/hash-router.ts +1 -0
- package/src/ui/nav.ts +15 -2
- package/src/ui/screens/SurveyResponsesScreen.tsx +271 -0
- package/src/ui/screens/registry.tsx +6 -0
|
@@ -27,6 +27,13 @@ interface ActionMeta {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
const ACTIONS: ActionMeta[] = [
|
|
30
|
+
{
|
|
31
|
+
action: 'dek',
|
|
32
|
+
icon: 'sparkle',
|
|
33
|
+
title: 'Suggest subheading',
|
|
34
|
+
description: 'Display copy in house voice',
|
|
35
|
+
kind: 'sync',
|
|
36
|
+
},
|
|
30
37
|
{
|
|
31
38
|
action: 'proofread',
|
|
32
39
|
icon: 'wand',
|
|
@@ -74,6 +81,10 @@ interface PanelState {
|
|
|
74
81
|
headlines: string[]
|
|
75
82
|
/** Meta description text from the sync result (for the "Apply" button). */
|
|
76
83
|
metaDescription: string | null
|
|
84
|
+
/** Generated display subheading. */
|
|
85
|
+
dek: string | null
|
|
86
|
+
/** Whether the generated subheading was applied to the controlled form. */
|
|
87
|
+
dekApplied: boolean
|
|
77
88
|
/** Whether the meta was applied/written. */
|
|
78
89
|
metaApplied: boolean
|
|
79
90
|
/** Whether the takeaways were written (server-side). */
|
|
@@ -89,6 +100,8 @@ const INITIAL_STATE: PanelState = {
|
|
|
89
100
|
activeAction: null,
|
|
90
101
|
headlines: [],
|
|
91
102
|
metaDescription: null,
|
|
103
|
+
dek: null,
|
|
104
|
+
dekApplied: false,
|
|
92
105
|
metaApplied: false,
|
|
93
106
|
takeawaysWritten: false,
|
|
94
107
|
locked: false,
|
|
@@ -103,9 +116,11 @@ export interface AiAssistPanelProps {
|
|
|
103
116
|
docId: string
|
|
104
117
|
/** Called when the meta description should be applied to the SEO description field. */
|
|
105
118
|
onMetaApplied?: (description: string) => void
|
|
119
|
+
/** Called after an unlocked generated dek is stored, keeping the form in sync. */
|
|
120
|
+
onDekApplied?: (dek: string) => void
|
|
106
121
|
}
|
|
107
122
|
|
|
108
|
-
export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
|
|
123
|
+
export function AiAssistPanel({ docId, onMetaApplied, onDekApplied }: AiAssistPanelProps) {
|
|
109
124
|
const [state, setState] = useState<PanelState>(INITIAL_STATE)
|
|
110
125
|
|
|
111
126
|
const handleAction = useCallback(
|
|
@@ -118,6 +133,8 @@ export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
|
|
|
118
133
|
activeAction: meta.action,
|
|
119
134
|
headlines: [],
|
|
120
135
|
metaDescription: null,
|
|
136
|
+
dek: null,
|
|
137
|
+
dekApplied: false,
|
|
121
138
|
metaApplied: false,
|
|
122
139
|
takeawaysWritten: false,
|
|
123
140
|
locked: false,
|
|
@@ -142,7 +159,18 @@ export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
|
|
|
142
159
|
} else {
|
|
143
160
|
// Sync: takeaways or meta-description
|
|
144
161
|
const data = (await syncAi(meta.action, docId)) as Record<string, unknown>
|
|
145
|
-
if (meta.action === '
|
|
162
|
+
if (meta.action === 'dek') {
|
|
163
|
+
const dek = typeof data.dek === 'string' ? data.dek : null
|
|
164
|
+
const written = data.written === true
|
|
165
|
+
if (dek && written) onDekApplied?.(dek)
|
|
166
|
+
setState((prev) => ({
|
|
167
|
+
...prev,
|
|
168
|
+
running: null,
|
|
169
|
+
dek,
|
|
170
|
+
dekApplied: Boolean(dek && written),
|
|
171
|
+
locked: data.locked === true,
|
|
172
|
+
}))
|
|
173
|
+
} else if (meta.action === 'meta-description') {
|
|
146
174
|
setState((prev) => ({
|
|
147
175
|
...prev,
|
|
148
176
|
running: null,
|
|
@@ -167,7 +195,7 @@ export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
|
|
|
167
195
|
}))
|
|
168
196
|
}
|
|
169
197
|
},
|
|
170
|
-
[docId],
|
|
198
|
+
[docId, onDekApplied],
|
|
171
199
|
)
|
|
172
200
|
|
|
173
201
|
const applyMeta = useCallback(async () => {
|
|
@@ -262,6 +290,30 @@ export function AiAssistPanel({ docId, onMetaApplied }: AiAssistPanelProps) {
|
|
|
262
290
|
)}
|
|
263
291
|
|
|
264
292
|
{/* Result pane — sync: meta description */}
|
|
293
|
+
{state.activeAction === 'dek' && state.dek !== null && (
|
|
294
|
+
<div style={resultPane}>
|
|
295
|
+
<div style={resultHeader}>
|
|
296
|
+
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ink-soft)' }}>
|
|
297
|
+
Suggested subheading
|
|
298
|
+
</span>
|
|
299
|
+
{state.locked && (
|
|
300
|
+
<span style={{ fontSize: 11, color: 'var(--amber)', marginLeft: 6 }}>
|
|
301
|
+
field locked — not written
|
|
302
|
+
</span>
|
|
303
|
+
)}
|
|
304
|
+
</div>
|
|
305
|
+
<p style={{ fontSize: 13, color: 'var(--ink)', lineHeight: 1.5, margin: '6px 0' }}>
|
|
306
|
+
{state.dek}
|
|
307
|
+
</p>
|
|
308
|
+
{state.dekApplied && (
|
|
309
|
+
<span style={{ fontSize: 12, color: 'var(--green)' }}>
|
|
310
|
+
<Icon name="checkCircle" size={13} style={{ marginRight: 4 }} />
|
|
311
|
+
Applied to subheading
|
|
312
|
+
</span>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
|
|
265
317
|
{state.activeAction === 'meta-description' && state.metaDescription !== null && (
|
|
266
318
|
<div style={resultPane}>
|
|
267
319
|
<div style={resultHeader}>
|
|
@@ -795,7 +795,9 @@ export function ContentForm({
|
|
|
795
795
|
</div>
|
|
796
796
|
|
|
797
797
|
{/* AI assist panel — shown when toggle is on and doc is saved */}
|
|
798
|
-
{form.aiOpen && form.savedId !== null &&
|
|
798
|
+
{form.aiOpen && form.savedId !== null && (
|
|
799
|
+
<AiAssistPanel docId={form.savedId} onDekApplied={handleDekChange} />
|
|
800
|
+
)}
|
|
799
801
|
|
|
800
802
|
{/* Title */}
|
|
801
803
|
<div style={fieldGroup}>
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// streamAi(action, docId, onChunk) → reads SSE body incrementally
|
|
11
11
|
// syncAi(action, docId) → POSTs and returns parsed JSON
|
|
12
12
|
|
|
13
|
-
/** The
|
|
14
|
-
export type AiAction = 'proofread' | 'headlines' | 'takeaways' | 'meta-description'
|
|
13
|
+
/** The AI actions supported by the panel. */
|
|
14
|
+
export type AiAction = 'dek' | 'proofread' | 'headlines' | 'takeaways' | 'meta-description'
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Build the URL for a given AI action + doc id.
|
package/src/ui/hash-router.ts
CHANGED
package/src/ui/nav.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// src/ui/nav.ts — the Masthead sidebar nav model. Two groups, 11 items
|
|
2
2
|
// (spec §6: Social Share added between Subscriptions and Settings).
|
|
3
3
|
|
|
4
|
+
import type { CmsRole } from '../schema/types.js'
|
|
5
|
+
import { canViewSurveyResponses } from '../surveys/access.js'
|
|
4
6
|
import type { Route } from './hash-router.js'
|
|
5
7
|
import type { IconName } from './icons.js'
|
|
6
8
|
|
|
@@ -33,6 +35,7 @@ export const NAV_MODEL: NavGroup[] = [
|
|
|
33
35
|
{ id: 'topics', label: 'Topics & tags', icon: 'tag' },
|
|
34
36
|
{ id: 'content-insights', label: 'Content Insights', icon: 'sparkle' },
|
|
35
37
|
{ id: 'subscriptions', label: 'Subscriptions', icon: 'inbox' },
|
|
38
|
+
{ id: 'survey-responses', label: 'Survey responses', icon: 'chart' },
|
|
36
39
|
{ id: 'social-share', label: 'Social Share', icon: 'share' },
|
|
37
40
|
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
|
38
41
|
],
|
|
@@ -40,9 +43,19 @@ export const NAV_MODEL: NavGroup[] = [
|
|
|
40
43
|
]
|
|
41
44
|
|
|
42
45
|
/** Build the nav with live counts merged onto matching item ids. */
|
|
43
|
-
export function navItems(
|
|
46
|
+
export function navItems(
|
|
47
|
+
counts: Partial<Record<Route, number>> = {},
|
|
48
|
+
options: { surveyResultsEnabled?: boolean; role?: CmsRole | null } = {
|
|
49
|
+
surveyResultsEnabled: true,
|
|
50
|
+
role: 'owner',
|
|
51
|
+
},
|
|
52
|
+
): NavGroup[] {
|
|
53
|
+
const canViewSurvey =
|
|
54
|
+
options.surveyResultsEnabled !== false && canViewSurveyResponses(options.role)
|
|
44
55
|
return NAV_MODEL.map((g) => ({
|
|
45
56
|
group: g.group,
|
|
46
|
-
items: g.items
|
|
57
|
+
items: g.items
|
|
58
|
+
.filter((item) => item.id !== 'survey-responses' || canViewSurvey)
|
|
59
|
+
.map((i) => (counts[i.id] != null ? { ...i, count: counts[i.id] } : { ...i })),
|
|
47
60
|
}))
|
|
48
61
|
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
isSurveyErrorCode,
|
|
4
|
+
SURVEY_ERROR_CODE_HEADER,
|
|
5
|
+
SURVEY_ERROR_DEFINITIONS,
|
|
6
|
+
type SurveyErrorCode,
|
|
7
|
+
} from '../../surveys/errors.js'
|
|
8
|
+
import { type SurveyResults, SurveyResultsSchema } from '../../surveys/schema.js'
|
|
9
|
+
|
|
10
|
+
export type SurveyResponsesState =
|
|
11
|
+
| { kind: 'loading' }
|
|
12
|
+
| { kind: 'ready'; data: SurveyResults }
|
|
13
|
+
| { kind: 'forbidden' }
|
|
14
|
+
| { kind: 'unavailable' }
|
|
15
|
+
| { kind: 'malformed' }
|
|
16
|
+
| { kind: 'not_found' }
|
|
17
|
+
| { kind: 'retryable' }
|
|
18
|
+
|
|
19
|
+
export function parseSurveyResultsPayload(value: unknown): SurveyResults | null {
|
|
20
|
+
const parsed = SurveyResultsSchema.safeParse(value)
|
|
21
|
+
return parsed.success ? parsed.data : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function classifySurveyResultsResponse(
|
|
25
|
+
response: Response,
|
|
26
|
+
): Promise<SurveyResponsesState> {
|
|
27
|
+
if (response.ok) {
|
|
28
|
+
const data = parseSurveyResultsPayload(await response.json().catch(() => null))
|
|
29
|
+
return data ? { kind: 'ready', data } : { kind: 'malformed' }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const code = await readSurveyErrorCode(response)
|
|
33
|
+
if ((response.status === 401 || response.status === 403) && code === 'survey_forbidden') {
|
|
34
|
+
return { kind: 'forbidden' }
|
|
35
|
+
}
|
|
36
|
+
if (response.status === 404 && code === 'survey_not_found') return { kind: 'not_found' }
|
|
37
|
+
if (response.status === 502 && code === 'survey_provider_malformed') {
|
|
38
|
+
return { kind: 'malformed' }
|
|
39
|
+
}
|
|
40
|
+
if (response.status === 503 && code === 'survey_provider_unavailable') {
|
|
41
|
+
return { kind: 'unavailable' }
|
|
42
|
+
}
|
|
43
|
+
return { kind: 'retryable' }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function SurveyResponsesScreen({ surveyId }: { surveyId: string }) {
|
|
47
|
+
const [state, setState] = useState<SurveyResponsesState>({ kind: 'loading' })
|
|
48
|
+
const load = useCallback(async () => {
|
|
49
|
+
setState({ kind: 'loading' })
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch(`/admin/api/surveys/${encodeURIComponent(surveyId)}`)
|
|
52
|
+
setState(await classifySurveyResultsResponse(response))
|
|
53
|
+
} catch {
|
|
54
|
+
setState({ kind: 'retryable' })
|
|
55
|
+
}
|
|
56
|
+
}, [surveyId])
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
void load()
|
|
60
|
+
}, [load])
|
|
61
|
+
|
|
62
|
+
return <SurveyResponsesBody surveyId={surveyId} state={state} onRetry={load} />
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function SurveyResponsesBody({
|
|
66
|
+
surveyId,
|
|
67
|
+
state,
|
|
68
|
+
onRetry,
|
|
69
|
+
}: {
|
|
70
|
+
surveyId: string
|
|
71
|
+
state: SurveyResponsesState
|
|
72
|
+
onRetry?: () => void
|
|
73
|
+
}) {
|
|
74
|
+
if (state.kind === 'loading') return <StatusPage title="Loading survey responses…" />
|
|
75
|
+
if (state.kind === 'forbidden')
|
|
76
|
+
return <StatusPage title="You do not have permission to view survey responses." />
|
|
77
|
+
if (state.kind === 'unavailable')
|
|
78
|
+
return <StatusPage title="Survey reporting is not configured for this site." />
|
|
79
|
+
if (state.kind === 'malformed')
|
|
80
|
+
return <StatusPage title="Masthead received invalid survey data." retry={onRetry} />
|
|
81
|
+
if (state.kind === 'not_found')
|
|
82
|
+
return <StatusPage title="The configured survey could not be found." />
|
|
83
|
+
if (state.kind === 'retryable')
|
|
84
|
+
return <StatusPage title="Survey responses could not be loaded." retry={onRetry} />
|
|
85
|
+
|
|
86
|
+
const { data } = state
|
|
87
|
+
const freeTextByQuestion = new Map<string, SurveyResults['freeText']>()
|
|
88
|
+
for (const entry of data.freeText) {
|
|
89
|
+
const entries = freeTextByQuestion.get(entry.questionId) ?? []
|
|
90
|
+
entries.push(entry)
|
|
91
|
+
freeTextByQuestion.set(entry.questionId, entries)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="page page-wide fade-in" style={{ maxWidth: 1340 }}>
|
|
96
|
+
<div className="page-head">
|
|
97
|
+
<div>
|
|
98
|
+
<h1 className="page-title">{data.survey.title}</h1>
|
|
99
|
+
<div className="page-sub">
|
|
100
|
+
{data.survey.responseCount} {data.survey.responseCount === 1 ? 'response' : 'responses'}{' '}
|
|
101
|
+
· {data.survey.status}
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
<a
|
|
105
|
+
className="btn"
|
|
106
|
+
href={`/admin/api/surveys/${encodeURIComponent(surveyId)}/responses.csv`}
|
|
107
|
+
>
|
|
108
|
+
Export CSV
|
|
109
|
+
</a>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
{data.survey.responseCount === 0 ? (
|
|
113
|
+
<div className="card" role="status">
|
|
114
|
+
<h2 className="card-title">No responses yet</h2>
|
|
115
|
+
<p className="page-sub">Aggregates and free-text answers will appear after submission.</p>
|
|
116
|
+
</div>
|
|
117
|
+
) : (
|
|
118
|
+
<section className="card" aria-labelledby="survey-response-list">
|
|
119
|
+
<h2 className="card-title" id="survey-response-list">
|
|
120
|
+
Recent responses
|
|
121
|
+
</h2>
|
|
122
|
+
<div className="masthead-rule" />
|
|
123
|
+
<table className="tbl">
|
|
124
|
+
<thead>
|
|
125
|
+
<tr>
|
|
126
|
+
<th>Submitted</th>
|
|
127
|
+
<th>Channel</th>
|
|
128
|
+
<th>Audience</th>
|
|
129
|
+
<th>Attribution</th>
|
|
130
|
+
</tr>
|
|
131
|
+
</thead>
|
|
132
|
+
<tbody>
|
|
133
|
+
{data.responses.map((response) => (
|
|
134
|
+
<tr key={response.id}>
|
|
135
|
+
<td>
|
|
136
|
+
<time dateTime={response.submittedAt}>{formatTime(response.submittedAt)}</time>
|
|
137
|
+
</td>
|
|
138
|
+
<td>{response.channel}</td>
|
|
139
|
+
<td>{segmentLabel(response.respondentSegment)}</td>
|
|
140
|
+
<td>{response.anonymous ? 'Anonymous' : 'Known respondent'}</td>
|
|
141
|
+
</tr>
|
|
142
|
+
))}
|
|
143
|
+
</tbody>
|
|
144
|
+
</table>
|
|
145
|
+
</section>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
<section aria-labelledby="survey-aggregates" style={{ marginTop: 24 }}>
|
|
149
|
+
<h2 className="page-title" id="survey-aggregates" style={{ fontSize: 22 }}>
|
|
150
|
+
Question aggregates
|
|
151
|
+
</h2>
|
|
152
|
+
<div className="grid-split-wide" style={{ alignItems: 'start', marginTop: 12 }}>
|
|
153
|
+
{data.questions.map((question) => (
|
|
154
|
+
<article className="card" key={question.id}>
|
|
155
|
+
<div className="kicker">
|
|
156
|
+
Q{question.position + 1} · {questionAudienceLabel(question.audience)}
|
|
157
|
+
</div>
|
|
158
|
+
<h3 className="card-title">{question.prompt}</h3>
|
|
159
|
+
{question.counts && <CountTable values={question.counts} />}
|
|
160
|
+
{question.distribution && (
|
|
161
|
+
<>
|
|
162
|
+
<p className="page-sub">
|
|
163
|
+
Average: {question.average === null ? '—' : question.average.toFixed(2)}
|
|
164
|
+
</p>
|
|
165
|
+
<CountTable values={question.distribution} />
|
|
166
|
+
</>
|
|
167
|
+
)}
|
|
168
|
+
{question.kind === 'text' && (
|
|
169
|
+
<p className="page-sub">{question.responseCount ?? 0} free-text responses</p>
|
|
170
|
+
)}
|
|
171
|
+
</article>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
</section>
|
|
175
|
+
|
|
176
|
+
<section aria-labelledby="survey-free-text" style={{ marginTop: 24 }}>
|
|
177
|
+
<h2 className="page-title" id="survey-free-text" style={{ fontSize: 22 }}>
|
|
178
|
+
Free-text responses
|
|
179
|
+
</h2>
|
|
180
|
+
{data.questions
|
|
181
|
+
.filter((question) => question.kind === 'text')
|
|
182
|
+
.map((question) => (
|
|
183
|
+
<div className="card" key={question.id} style={{ marginTop: 12 }}>
|
|
184
|
+
<h3 className="card-title">{question.prompt}</h3>
|
|
185
|
+
{(freeTextByQuestion.get(question.id) ?? []).length === 0 ? (
|
|
186
|
+
<p className="page-sub">No free-text responses.</p>
|
|
187
|
+
) : (
|
|
188
|
+
<ul>
|
|
189
|
+
{(freeTextByQuestion.get(question.id) ?? []).map((entry) => (
|
|
190
|
+
<li key={`${entry.responseId}:${entry.submittedAt}`}>{entry.value}</li>
|
|
191
|
+
))}
|
|
192
|
+
</ul>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
))}
|
|
196
|
+
</section>
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function readSurveyErrorCode(response: Response): Promise<SurveyErrorCode | null> {
|
|
202
|
+
const headerCode = response.headers.get(SURVEY_ERROR_CODE_HEADER)
|
|
203
|
+
if (!isSurveyErrorCode(headerCode)) return null
|
|
204
|
+
if (headerCode === 'survey_forbidden' && (response.status === 401 || response.status === 403)) {
|
|
205
|
+
return headerCode
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const body = await response.json().catch(() => null)
|
|
209
|
+
if (!isPlainRecord(body) || Object.keys(body).length !== 2) return null
|
|
210
|
+
if (!isSurveyErrorCode(body.code) || body.code !== headerCode) return null
|
|
211
|
+
return body.error === SURVEY_ERROR_DEFINITIONS[body.code].error ? body.code : null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
215
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function StatusPage({
|
|
219
|
+
title,
|
|
220
|
+
detail,
|
|
221
|
+
retry,
|
|
222
|
+
}: {
|
|
223
|
+
title: string
|
|
224
|
+
detail?: string
|
|
225
|
+
retry?: () => void
|
|
226
|
+
}) {
|
|
227
|
+
return (
|
|
228
|
+
<div className="page" role="status">
|
|
229
|
+
<h1 className="page-title">Survey responses</h1>
|
|
230
|
+
<p className="page-sub">{title}</p>
|
|
231
|
+
{detail && <p className="page-sub">{detail}</p>}
|
|
232
|
+
{retry && (
|
|
233
|
+
<button type="button" className="btn" onClick={retry}>
|
|
234
|
+
Retry
|
|
235
|
+
</button>
|
|
236
|
+
)}
|
|
237
|
+
</div>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function CountTable({ values }: { values: Record<string, number> }) {
|
|
242
|
+
return (
|
|
243
|
+
<table className="tbl">
|
|
244
|
+
<tbody>
|
|
245
|
+
{Object.entries(values).map(([label, count]) => (
|
|
246
|
+
<tr key={label}>
|
|
247
|
+
<th scope="row">{label}</th>
|
|
248
|
+
<td className="tnum">{count}</td>
|
|
249
|
+
</tr>
|
|
250
|
+
))}
|
|
251
|
+
</tbody>
|
|
252
|
+
</table>
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function formatTime(value: string): string {
|
|
257
|
+
const date = new Date(value)
|
|
258
|
+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function segmentLabel(value: 'member' | 'non_member' | 'unknown'): string {
|
|
262
|
+
if (value === 'member') return 'Member'
|
|
263
|
+
if (value === 'non_member') return 'Non-member'
|
|
264
|
+
return 'Unknown'
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function questionAudienceLabel(value: 'all' | 'member' | 'non_member'): string {
|
|
268
|
+
if (value === 'member') return 'Members only'
|
|
269
|
+
if (value === 'non_member') return 'Non-members only'
|
|
270
|
+
return 'All respondents'
|
|
271
|
+
}
|
|
@@ -23,6 +23,7 @@ import { MediaScreen } from './MediaScreen.js'
|
|
|
23
23
|
import { SettingsScreen } from './SettingsScreen.js'
|
|
24
24
|
import { SocialShareScreen } from './SocialShareScreen.js'
|
|
25
25
|
import { SubscriptionsScreen } from './SubscriptionsScreen.js'
|
|
26
|
+
import { SurveyResponsesScreen } from './SurveyResponsesScreen.js'
|
|
26
27
|
import { TopicsScreen } from './TopicsScreen.js'
|
|
27
28
|
|
|
28
29
|
// Thin wrappers that supply the openDoc seam for screens that need it.
|
|
@@ -40,8 +41,10 @@ function CalendarRoute() {
|
|
|
40
41
|
|
|
41
42
|
export function createCmsScreens({
|
|
42
43
|
allowInlineAuthorCreation = true,
|
|
44
|
+
surveyId,
|
|
43
45
|
}: {
|
|
44
46
|
allowInlineAuthorCreation?: boolean
|
|
47
|
+
surveyId?: string
|
|
45
48
|
} = {}): ScreenRegistry {
|
|
46
49
|
return {
|
|
47
50
|
content: () => <ContentRoute allowInlineAuthorCreation={allowInlineAuthorCreation} />,
|
|
@@ -53,6 +56,9 @@ export function createCmsScreens({
|
|
|
53
56
|
authors: () => <AuthorsScreen />,
|
|
54
57
|
topics: () => <TopicsScreen />,
|
|
55
58
|
subscriptions: () => <SubscriptionsScreen />,
|
|
59
|
+
...(surveyId
|
|
60
|
+
? { 'survey-responses': () => <SurveyResponsesScreen surveyId={surveyId} /> }
|
|
61
|
+
: {}),
|
|
56
62
|
'social-share': () => (
|
|
57
63
|
<SocialShareScreen allowInlineAuthorCreation={allowInlineAuthorCreation} />
|
|
58
64
|
),
|