@growth-labs/cms 0.5.5 → 0.5.6

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 (113) hide show
  1. package/README.md +36 -0
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +2 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/integration/index.d.ts.map +1 -1
  7. package/dist/integration/index.js +9 -0
  8. package/dist/integration/index.js.map +1 -1
  9. package/dist/integration/options.d.ts +19 -0
  10. package/dist/integration/options.d.ts.map +1 -1
  11. package/dist/integration/options.js +7 -0
  12. package/dist/integration/options.js.map +1 -1
  13. package/dist/providers/null.d.ts +2 -1
  14. package/dist/providers/null.d.ts.map +1 -1
  15. package/dist/providers/null.js +9 -0
  16. package/dist/providers/null.js.map +1 -1
  17. package/dist/providers/types.d.ts +10 -0
  18. package/dist/providers/types.d.ts.map +1 -1
  19. package/dist/providers/types.js.map +1 -1
  20. package/dist/routes/authz-matrix.d.ts.map +1 -1
  21. package/dist/routes/authz-matrix.js +2 -0
  22. package/dist/routes/authz-matrix.js.map +1 -1
  23. package/dist/routes/context.d.ts +1 -1
  24. package/dist/routes/context.d.ts.map +1 -1
  25. package/dist/routes/context.js.map +1 -1
  26. package/dist/routes/index.d.ts +1 -0
  27. package/dist/routes/index.d.ts.map +1 -1
  28. package/dist/routes/index.js +2 -0
  29. package/dist/routes/index.js.map +1 -1
  30. package/dist/routes/surveys.d.ts +12 -0
  31. package/dist/routes/surveys.d.ts.map +1 -0
  32. package/dist/routes/surveys.js +109 -0
  33. package/dist/routes/surveys.js.map +1 -0
  34. package/dist/schema/insights-ingest.d.ts +46 -46
  35. package/dist/surveys/access.d.ts +4 -0
  36. package/dist/surveys/access.d.ts.map +1 -0
  37. package/dist/surveys/access.js +9 -0
  38. package/dist/surveys/access.js.map +1 -0
  39. package/dist/surveys/errors.d.ts +43 -0
  40. package/dist/surveys/errors.d.ts.map +1 -0
  41. package/dist/surveys/errors.js +42 -0
  42. package/dist/surveys/errors.js.map +1 -0
  43. package/dist/surveys/schema.d.ts +939 -0
  44. package/dist/surveys/schema.d.ts.map +1 -0
  45. package/dist/surveys/schema.js +385 -0
  46. package/dist/surveys/schema.js.map +1 -0
  47. package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts +3 -0
  48. package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts.map +1 -0
  49. package/dist/ui/api/surveys/[surveyId]/responses.csv.js +17 -0
  50. package/dist/ui/api/surveys/[surveyId]/responses.csv.js.map +1 -0
  51. package/dist/ui/api/surveys/[surveyId].d.ts +3 -0
  52. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -0
  53. package/dist/ui/api/surveys/[surveyId].js +17 -0
  54. package/dist/ui/api/surveys/[surveyId].js.map +1 -0
  55. package/dist/ui/client/mount.d.ts.map +1 -1
  56. package/dist/ui/client/mount.js +1 -0
  57. package/dist/ui/client/mount.js.map +1 -1
  58. package/dist/ui/commands.d.ts +5 -1
  59. package/dist/ui/commands.d.ts.map +1 -1
  60. package/dist/ui/commands.js +7 -0
  61. package/dist/ui/commands.js.map +1 -1
  62. package/dist/ui/components/CmsApp.d.ts +2 -0
  63. package/dist/ui/components/CmsApp.d.ts.map +1 -1
  64. package/dist/ui/components/CmsApp.js +14 -3
  65. package/dist/ui/components/CmsApp.js.map +1 -1
  66. package/dist/ui/components/CommandPalette.d.ts +2 -0
  67. package/dist/ui/components/CommandPalette.d.ts.map +1 -1
  68. package/dist/ui/components/CommandPalette.js +1 -1
  69. package/dist/ui/components/CommandPalette.js.map +1 -1
  70. package/dist/ui/components/Sidebar.d.ts +1 -0
  71. package/dist/ui/components/Sidebar.d.ts.map +1 -1
  72. package/dist/ui/components/Sidebar.js +1 -1
  73. package/dist/ui/components/Sidebar.js.map +1 -1
  74. package/dist/ui/hash-router.d.ts +1 -1
  75. package/dist/ui/hash-router.d.ts.map +1 -1
  76. package/dist/ui/hash-router.js +1 -0
  77. package/dist/ui/hash-router.js.map +1 -1
  78. package/dist/ui/nav.d.ts +5 -1
  79. package/dist/ui/nav.d.ts.map +1 -1
  80. package/dist/ui/nav.js +10 -2
  81. package/dist/ui/nav.js.map +1 -1
  82. package/dist/ui/screens/SurveyResponsesScreen.d.ts +28 -0
  83. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -0
  84. package/dist/ui/screens/SurveyResponsesScreen.js +110 -0
  85. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -0
  86. package/dist/ui/screens/registry.d.ts +2 -1
  87. package/dist/ui/screens/registry.d.ts.map +1 -1
  88. package/dist/ui/screens/registry.js +5 -1
  89. package/dist/ui/screens/registry.js.map +1 -1
  90. package/package.json +1 -1
  91. package/src/index.ts +2 -2
  92. package/src/integration/index.ts +9 -0
  93. package/src/integration/options.ts +8 -0
  94. package/src/providers/null.ts +19 -0
  95. package/src/providers/types.ts +7 -0
  96. package/src/routes/authz-matrix.ts +2 -0
  97. package/src/routes/context.ts +2 -0
  98. package/src/routes/index.ts +6 -0
  99. package/src/routes/surveys.ts +130 -0
  100. package/src/surveys/access.ts +11 -0
  101. package/src/surveys/errors.ts +51 -0
  102. package/src/surveys/schema.ts +435 -0
  103. package/src/ui/api/surveys/[surveyId]/responses.csv.ts +23 -0
  104. package/src/ui/api/surveys/[surveyId].ts +23 -0
  105. package/src/ui/client/mount.tsx +1 -0
  106. package/src/ui/commands.ts +14 -1
  107. package/src/ui/components/CmsApp.tsx +25 -3
  108. package/src/ui/components/CommandPalette.tsx +2 -1
  109. package/src/ui/components/Sidebar.tsx +10 -2
  110. package/src/ui/hash-router.ts +1 -0
  111. package/src/ui/nav.ts +15 -2
  112. package/src/ui/screens/SurveyResponsesScreen.tsx +271 -0
  113. package/src/ui/screens/registry.tsx +6 -0
@@ -1,6 +1,7 @@
1
1
  // src/ui/commands.ts — the ⌘K command set + the pure filter. "create"/"jump"
2
2
  // are static; "doc" entries are fed in from the /admin/api/search results.
3
- import type { ContentType } from '../schema/types.js'
3
+ import type { CmsRole, ContentType } from '../schema/types.js'
4
+ import { canViewSurveyResponses } from '../surveys/access.js'
4
5
  import { ROUTES, type Route } from './hash-router.js'
5
6
 
6
7
  export type Command =
@@ -24,6 +25,7 @@ const JUMP_LABELS: Record<Route, string> = {
24
25
  topics: 'Topics & tags',
25
26
  'content-insights': 'Content Insights',
26
27
  subscriptions: 'Subscriptions',
28
+ 'survey-responses': 'Survey responses',
27
29
  'social-share': 'Social Share',
28
30
  settings: 'Settings',
29
31
  }
@@ -37,6 +39,7 @@ const JUMP_ICONS: Record<Route, string> = {
37
39
  topics: 'tag',
38
40
  'content-insights': 'sparkle',
39
41
  subscriptions: 'inbox',
42
+ 'survey-responses': 'chart',
40
43
  'social-share': 'share',
41
44
  settings: 'settings',
42
45
  }
@@ -57,6 +60,16 @@ export const baseCommands: Command[] = [
57
60
  ),
58
61
  ]
59
62
 
63
+ export function commandsForAccess(options: {
64
+ surveyResultsEnabled: boolean
65
+ role: CmsRole | null
66
+ }): Command[] {
67
+ const canViewSurvey = options.surveyResultsEnabled && canViewSurveyResponses(options.role)
68
+ return baseCommands.filter(
69
+ (command) => command.kind !== 'jump' || command.route !== 'survey-responses' || canViewSurvey,
70
+ )
71
+ }
72
+
60
73
  export function filterCommands(query: string, all: Command[]): Command[] {
61
74
  const q = query.trim().toLowerCase()
62
75
  if (!q) return all.slice(0, 9)
@@ -1,9 +1,12 @@
1
1
  import { Component, type ReactNode, useEffect, useState } from 'react'
2
+ import type { CmsRole } from '../../schema/types.js'
3
+ import { canViewSurveyResponses } from '../../surveys/access.js'
2
4
  import type { BootState } from '../client/boot-state.js'
5
+ import { commandsForAccess } from '../commands.js'
3
6
  import type { Route } from '../hash-router.js'
4
7
  import { useHashRouter } from '../use-hash-router.js'
5
8
  import { useTweaks } from '../use-tweaks.js'
6
- import { WorkspaceProvider, type WorkspaceState } from '../workspace-context.js'
9
+ import { useWorkspace, WorkspaceProvider, type WorkspaceState } from '../workspace-context.js'
7
10
  import { CommandPalette } from './CommandPalette.js'
8
11
  import { NoAccessScreen } from './NoAccessScreen.js'
9
12
  import { Sidebar } from './Sidebar.js'
@@ -33,6 +36,7 @@ export function CmsApp(props: {
33
36
 
34
37
  function Shell({ screens }: { screens: ScreenRegistry }) {
35
38
  const { route, navigate } = useHashRouter()
39
+ const { state } = useWorkspace()
36
40
  const { t, setTweak } = useTweaks()
37
41
  const [cmd, setCmd] = useState(false)
38
42
  const [collapsed, setCollapsed] = useState(false)
@@ -47,10 +51,15 @@ function Shell({ screens }: { screens: ScreenRegistry }) {
47
51
  window.addEventListener('keydown', h)
48
52
  return () => window.removeEventListener('keydown', h)
49
53
  }, [])
50
- const screen = screens[route]
54
+ const screen = resolveScreenForAccess(screens, route, state.user?.role ?? null)
51
55
  return (
52
56
  <div className="app" data-collapsed={collapsed}>
53
- <Sidebar route={route} navigate={navigate} collapsed={collapsed} />
57
+ <Sidebar
58
+ route={route}
59
+ navigate={navigate}
60
+ collapsed={collapsed}
61
+ surveyResultsEnabled={Boolean(screens['survey-responses'])}
62
+ />
54
63
  <div className="main">
55
64
  <Topbar
56
65
  collapsed={collapsed}
@@ -70,12 +79,25 @@ function Shell({ screens }: { screens: ScreenRegistry }) {
70
79
  onClose={() => setCmd(false)}
71
80
  navigate={navigate}
72
81
  openDoc={() => setCmd(false)}
82
+ commands={commandsForAccess({
83
+ surveyResultsEnabled: Boolean(screens['survey-responses']),
84
+ role: state.user?.role ?? null,
85
+ })}
73
86
  />
74
87
  )}
75
88
  </div>
76
89
  )
77
90
  }
78
91
 
92
+ export function resolveScreenForAccess(
93
+ screens: ScreenRegistry,
94
+ route: Route,
95
+ role: CmsRole | null,
96
+ ): (() => ReactNode) | undefined {
97
+ if (route === 'survey-responses' && !canViewSurveyResponses(role)) return undefined
98
+ return screens[route]
99
+ }
100
+
79
101
  function EmptyRoute({ route }: { route: Route }) {
80
102
  return (
81
103
  <div className="page" style={{ padding: 40, color: 'var(--ink-muted)' }}>
@@ -12,6 +12,7 @@ export function CommandPalette(props: {
12
12
  onClose: () => void
13
13
  navigate: (r: Route) => void
14
14
  openDoc: (id: string | null, type?: string) => void
15
+ commands?: Command[]
15
16
  }) {
16
17
  const [q, setQ] = useState('')
17
18
  const [docs, setDocs] = useState<Command[]>([])
@@ -35,7 +36,7 @@ export function CommandPalette(props: {
35
36
  .catch(() => {})
36
37
  return () => ctrl.abort()
37
38
  }, [q])
38
- const all = [...baseCommands, ...docs]
39
+ const all = [...(props.commands ?? baseCommands), ...docs]
39
40
  const filtered = filterCommands(q, all)
40
41
  function run(c: Command) {
41
42
  if (c.kind === 'jump') props.navigate(c.route)
@@ -5,10 +5,18 @@ import { navItems } from '../nav.js'
5
5
  import { useWorkspace } from '../workspace-context.js'
6
6
  import { SiteSwitcher } from './SiteSwitcher.js'
7
7
 
8
- export function Sidebar(props: { route: Route; navigate: (r: Route) => void; collapsed: boolean }) {
8
+ export function Sidebar(props: {
9
+ route: Route
10
+ navigate: (r: Route) => void
11
+ collapsed: boolean
12
+ surveyResultsEnabled?: boolean
13
+ }) {
9
14
  const { state } = useWorkspace()
10
- const groups = navItems()
11
15
  const user = state.user
16
+ const groups = navItems(
17
+ {},
18
+ { surveyResultsEnabled: props.surveyResultsEnabled, role: user?.role },
19
+ )
12
20
  const initial = user?.name?.[0]?.toUpperCase() ?? user?.email?.[0]?.toUpperCase() ?? '?'
13
21
 
14
22
  return (
@@ -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
  ),