@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.
- package/README.md +36 -0
- 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 +9 -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/authz-matrix.d.ts.map +1 -1
- package/dist/routes/authz-matrix.js +2 -0
- package/dist/routes/authz-matrix.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/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/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/index.ts +2 -2
- package/src/integration/index.ts +9 -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/authz-matrix.ts +2 -0
- package/src/routes/context.ts +2 -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/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/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
package/src/ui/commands.ts
CHANGED
|
@@ -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
|
|
54
|
+
const screen = resolveScreenForAccess(screens, route, state.user?.role ?? null)
|
|
51
55
|
return (
|
|
52
56
|
<div className="app" data-collapsed={collapsed}>
|
|
53
|
-
<Sidebar
|
|
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: {
|
|
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 (
|
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
|
),
|