@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.
Files changed (164) hide show
  1. package/README.md +46 -0
  2. package/dist/engine/ai-dek-notifications.d.ts +16 -0
  3. package/dist/engine/ai-dek-notifications.d.ts.map +1 -0
  4. package/dist/engine/ai-dek-notifications.js +34 -0
  5. package/dist/engine/ai-dek-notifications.js.map +1 -0
  6. package/dist/engine/ai-dek.d.ts +35 -0
  7. package/dist/engine/ai-dek.d.ts.map +1 -0
  8. package/dist/engine/ai-dek.js +99 -0
  9. package/dist/engine/ai-dek.js.map +1 -0
  10. package/dist/engine/ai-prompts.d.ts +12 -0
  11. package/dist/engine/ai-prompts.d.ts.map +1 -1
  12. package/dist/engine/ai-prompts.js +17 -0
  13. package/dist/engine/ai-prompts.js.map +1 -1
  14. package/dist/engine/index.d.ts +3 -1
  15. package/dist/engine/index.d.ts.map +1 -1
  16. package/dist/engine/index.js +4 -1
  17. package/dist/engine/index.js.map +1 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/integration/index.d.ts.map +1 -1
  23. package/dist/integration/index.js +13 -0
  24. package/dist/integration/index.js.map +1 -1
  25. package/dist/integration/options.d.ts +19 -0
  26. package/dist/integration/options.d.ts.map +1 -1
  27. package/dist/integration/options.js +7 -0
  28. package/dist/integration/options.js.map +1 -1
  29. package/dist/providers/null.d.ts +2 -1
  30. package/dist/providers/null.d.ts.map +1 -1
  31. package/dist/providers/null.js +9 -0
  32. package/dist/providers/null.js.map +1 -1
  33. package/dist/providers/types.d.ts +10 -0
  34. package/dist/providers/types.d.ts.map +1 -1
  35. package/dist/providers/types.js.map +1 -1
  36. package/dist/routes/ai.d.ts +2 -0
  37. package/dist/routes/ai.d.ts.map +1 -1
  38. package/dist/routes/ai.js +42 -0
  39. package/dist/routes/ai.js.map +1 -1
  40. package/dist/routes/authz-matrix.d.ts +2 -1
  41. package/dist/routes/authz-matrix.d.ts.map +1 -1
  42. package/dist/routes/authz-matrix.js +5 -0
  43. package/dist/routes/authz-matrix.js.map +1 -1
  44. package/dist/routes/content.d.ts.map +1 -1
  45. package/dist/routes/content.js +43 -1
  46. package/dist/routes/content.js.map +1 -1
  47. package/dist/routes/context.d.ts +1 -1
  48. package/dist/routes/context.d.ts.map +1 -1
  49. package/dist/routes/context.js.map +1 -1
  50. package/dist/routes/index.d.ts +1 -0
  51. package/dist/routes/index.d.ts.map +1 -1
  52. package/dist/routes/index.js +2 -0
  53. package/dist/routes/index.js.map +1 -1
  54. package/dist/routes/surveys.d.ts +12 -0
  55. package/dist/routes/surveys.d.ts.map +1 -0
  56. package/dist/routes/surveys.js +109 -0
  57. package/dist/routes/surveys.js.map +1 -0
  58. package/dist/schema/insights-ingest.d.ts +46 -46
  59. package/dist/surveys/access.d.ts +4 -0
  60. package/dist/surveys/access.d.ts.map +1 -0
  61. package/dist/surveys/access.js +9 -0
  62. package/dist/surveys/access.js.map +1 -0
  63. package/dist/surveys/errors.d.ts +43 -0
  64. package/dist/surveys/errors.d.ts.map +1 -0
  65. package/dist/surveys/errors.js +42 -0
  66. package/dist/surveys/errors.js.map +1 -0
  67. package/dist/surveys/schema.d.ts +939 -0
  68. package/dist/surveys/schema.d.ts.map +1 -0
  69. package/dist/surveys/schema.js +385 -0
  70. package/dist/surveys/schema.js.map +1 -0
  71. package/dist/ui/api/_content-config.d.ts.map +1 -1
  72. package/dist/ui/api/_content-config.js +1 -0
  73. package/dist/ui/api/_content-config.js.map +1 -1
  74. package/dist/ui/api/content/[id]/ai/dek.d.ts +3 -0
  75. package/dist/ui/api/content/[id]/ai/dek.d.ts.map +1 -0
  76. package/dist/ui/api/content/[id]/ai/dek.js +5 -0
  77. package/dist/ui/api/content/[id]/ai/dek.js.map +1 -0
  78. package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts +3 -0
  79. package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts.map +1 -0
  80. package/dist/ui/api/surveys/[surveyId]/responses.csv.js +17 -0
  81. package/dist/ui/api/surveys/[surveyId]/responses.csv.js.map +1 -0
  82. package/dist/ui/api/surveys/[surveyId].d.ts +3 -0
  83. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -0
  84. package/dist/ui/api/surveys/[surveyId].js +17 -0
  85. package/dist/ui/api/surveys/[surveyId].js.map +1 -0
  86. package/dist/ui/client/mount.d.ts.map +1 -1
  87. package/dist/ui/client/mount.js +1 -0
  88. package/dist/ui/client/mount.js.map +1 -1
  89. package/dist/ui/commands.d.ts +5 -1
  90. package/dist/ui/commands.d.ts.map +1 -1
  91. package/dist/ui/commands.js +7 -0
  92. package/dist/ui/commands.js.map +1 -1
  93. package/dist/ui/components/CmsApp.d.ts +2 -0
  94. package/dist/ui/components/CmsApp.d.ts.map +1 -1
  95. package/dist/ui/components/CmsApp.js +14 -3
  96. package/dist/ui/components/CmsApp.js.map +1 -1
  97. package/dist/ui/components/CommandPalette.d.ts +2 -0
  98. package/dist/ui/components/CommandPalette.d.ts.map +1 -1
  99. package/dist/ui/components/CommandPalette.js +1 -1
  100. package/dist/ui/components/CommandPalette.js.map +1 -1
  101. package/dist/ui/components/Sidebar.d.ts +1 -0
  102. package/dist/ui/components/Sidebar.d.ts.map +1 -1
  103. package/dist/ui/components/Sidebar.js +1 -1
  104. package/dist/ui/components/Sidebar.js.map +1 -1
  105. package/dist/ui/editor/AiAssistPanel.d.ts +3 -1
  106. package/dist/ui/editor/AiAssistPanel.d.ts.map +1 -1
  107. package/dist/ui/editor/AiAssistPanel.js +28 -4
  108. package/dist/ui/editor/AiAssistPanel.js.map +1 -1
  109. package/dist/ui/editor/ContentForm.d.ts.map +1 -1
  110. package/dist/ui/editor/ContentForm.js +1 -1
  111. package/dist/ui/editor/ContentForm.js.map +1 -1
  112. package/dist/ui/editor/ai-assist.d.ts +2 -2
  113. package/dist/ui/editor/ai-assist.d.ts.map +1 -1
  114. package/dist/ui/hash-router.d.ts +1 -1
  115. package/dist/ui/hash-router.d.ts.map +1 -1
  116. package/dist/ui/hash-router.js +1 -0
  117. package/dist/ui/hash-router.js.map +1 -1
  118. package/dist/ui/nav.d.ts +5 -1
  119. package/dist/ui/nav.d.ts.map +1 -1
  120. package/dist/ui/nav.js +10 -2
  121. package/dist/ui/nav.js.map +1 -1
  122. package/dist/ui/screens/SurveyResponsesScreen.d.ts +28 -0
  123. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -0
  124. package/dist/ui/screens/SurveyResponsesScreen.js +110 -0
  125. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -0
  126. package/dist/ui/screens/registry.d.ts +2 -1
  127. package/dist/ui/screens/registry.d.ts.map +1 -1
  128. package/dist/ui/screens/registry.js +5 -1
  129. package/dist/ui/screens/registry.js.map +1 -1
  130. package/package.json +1 -1
  131. package/src/engine/ai-dek-notifications.ts +50 -0
  132. package/src/engine/ai-dek.ts +138 -0
  133. package/src/engine/ai-prompts.ts +25 -0
  134. package/src/engine/index.ts +14 -0
  135. package/src/index.ts +2 -2
  136. package/src/integration/index.ts +13 -0
  137. package/src/integration/options.ts +8 -0
  138. package/src/providers/null.ts +19 -0
  139. package/src/providers/types.ts +7 -0
  140. package/src/routes/ai.ts +44 -0
  141. package/src/routes/authz-matrix.ts +14 -1
  142. package/src/routes/content.ts +45 -1
  143. package/src/routes/context.ts +3 -0
  144. package/src/routes/index.ts +6 -0
  145. package/src/routes/surveys.ts +130 -0
  146. package/src/surveys/access.ts +11 -0
  147. package/src/surveys/errors.ts +51 -0
  148. package/src/surveys/schema.ts +435 -0
  149. package/src/ui/api/_content-config.ts +2 -0
  150. package/src/ui/api/content/[id]/ai/dek.ts +8 -0
  151. package/src/ui/api/surveys/[surveyId]/responses.csv.ts +23 -0
  152. package/src/ui/api/surveys/[surveyId].ts +23 -0
  153. package/src/ui/client/mount.tsx +1 -0
  154. package/src/ui/commands.ts +14 -1
  155. package/src/ui/components/CmsApp.tsx +25 -3
  156. package/src/ui/components/CommandPalette.tsx +2 -1
  157. package/src/ui/components/Sidebar.tsx +10 -2
  158. package/src/ui/editor/AiAssistPanel.tsx +55 -3
  159. package/src/ui/editor/ContentForm.tsx +3 -1
  160. package/src/ui/editor/ai-assist.ts +2 -2
  161. package/src/ui/hash-router.ts +1 -0
  162. package/src/ui/nav.ts +15 -2
  163. package/src/ui/screens/SurveyResponsesScreen.tsx +271 -0
  164. 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 === 'meta-description') {
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 && <AiAssistPanel docId={form.savedId} />}
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 four AI actions supported by the panel. */
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.
@@ -11,6 +11,7 @@ export const ROUTES = [
11
11
  'topics',
12
12
  'content-insights',
13
13
  'subscriptions',
14
+ 'survey-responses',
14
15
  'social-share',
15
16
  'settings',
16
17
  ] as const
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(counts: Partial<Record<Route, number>> = {}): NavGroup[] {
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.map((i) => (counts[i.id] != null ? { ...i, count: counts[i.id] } : { ...i })),
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
  ),