@growth-labs/cms 0.5.4 → 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 +55 -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 +9 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/options.d.ts +38 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +13 -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/authors.d.ts.map +1 -1
- package/dist/routes/authors.js +33 -8
- package/dist/routes/authors.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/recovery-errors.d.ts +21 -0
- package/dist/routes/recovery-errors.d.ts.map +1 -0
- package/dist/routes/recovery-errors.js +53 -0
- package/dist/routes/recovery-errors.js.map +1 -0
- 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 +6 -2
- 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/ShareModal.d.ts +4 -1
- package/dist/ui/components/ShareModal.d.ts.map +1 -1
- package/dist/ui/components/ShareModal.js +39 -9
- package/dist/ui/components/ShareModal.js.map +1 -1
- package/dist/ui/components/SharePickers.d.ts +29 -2
- package/dist/ui/components/SharePickers.d.ts.map +1 -1
- package/dist/ui/components/SharePickers.js +33 -8
- package/dist/ui/components/SharePickers.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/AuthorsScreen.d.ts.map +1 -1
- package/dist/ui/screens/AuthorsScreen.js +2 -1
- package/dist/ui/screens/AuthorsScreen.js.map +1 -1
- package/dist/ui/screens/ContentRoute.d.ts +3 -1
- package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
- package/dist/ui/screens/ContentRoute.js +3 -3
- package/dist/ui/screens/ContentRoute.js.map +1 -1
- package/dist/ui/screens/EditorScreen.d.ts +2 -1
- package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
- package/dist/ui/screens/EditorScreen.js +2 -2
- package/dist/ui/screens/EditorScreen.js.map +1 -1
- package/dist/ui/screens/LibraryScreen.d.ts +2 -1
- package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
- package/dist/ui/screens/LibraryScreen.js +2 -2
- package/dist/ui/screens/LibraryScreen.js.map +1 -1
- package/dist/ui/screens/SocialShareScreen.d.ts +3 -1
- package/dist/ui/screens/SocialShareScreen.d.ts.map +1 -1
- package/dist/ui/screens/SocialShareScreen.js +20 -8
- package/dist/ui/screens/SocialShareScreen.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/author-recovery.d.ts +11 -0
- package/dist/ui/screens/author-recovery.d.ts.map +1 -0
- package/dist/ui/screens/author-recovery.js +18 -0
- package/dist/ui/screens/author-recovery.js.map +1 -0
- package/dist/ui/screens/registry.d.ts +4 -0
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +20 -13
- package/dist/ui/screens/registry.js.map +1 -1
- package/dist/ui/screens/social-share-data.d.ts +23 -2
- package/dist/ui/screens/social-share-data.d.ts.map +1 -1
- package/dist/ui/screens/social-share-data.js +57 -11
- package/dist/ui/screens/social-share-data.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 +15 -0
- package/src/providers/null.ts +19 -0
- package/src/providers/types.ts +7 -0
- package/src/routes/authors.ts +41 -20
- 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/recovery-errors.ts +70 -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 +6 -2
- 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/ShareModal.tsx +102 -19
- package/src/ui/components/SharePickers.tsx +76 -27
- 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/AuthorsScreen.tsx +7 -2
- package/src/ui/screens/ContentRoute.tsx +13 -3
- package/src/ui/screens/EditorScreen.tsx +7 -1
- package/src/ui/screens/LibraryScreen.tsx +3 -1
- package/src/ui/screens/SocialShareScreen.tsx +53 -6
- package/src/ui/screens/SurveyResponsesScreen.tsx +271 -0
- package/src/ui/screens/author-recovery.ts +32 -0
- package/src/ui/screens/registry.tsx +28 -12
- package/src/ui/screens/social-share-data.ts +92 -13
|
@@ -30,17 +30,25 @@
|
|
|
30
30
|
// Registry mount and per-content Share action are Task 9.
|
|
31
31
|
|
|
32
32
|
import { useCallback, useEffect, useReducer, useState } from 'react'
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
AuthorPicker,
|
|
35
|
+
ContentPicker,
|
|
36
|
+
HandlePicker,
|
|
37
|
+
type ShareLinksData,
|
|
38
|
+
useShareLinksData,
|
|
39
|
+
} from '../components/SharePickers.js'
|
|
34
40
|
import { type RecentLink, ShareStatsPanel } from '../components/ShareStatsPanel.js'
|
|
35
41
|
import { Icon } from '../icons.js'
|
|
36
42
|
import {
|
|
37
43
|
BOOST_MODES,
|
|
38
44
|
type BoostMode,
|
|
39
45
|
buildBatchPayloadFrom,
|
|
46
|
+
canonicalAuthorRosterStatus,
|
|
40
47
|
formatShareOutput,
|
|
41
48
|
initShareForm,
|
|
42
49
|
type LinkResult,
|
|
43
50
|
relevantFields,
|
|
51
|
+
requiresCanonicalAuthorRoster,
|
|
44
52
|
SHARE_MODES,
|
|
45
53
|
type ShareFormState,
|
|
46
54
|
type ShareMode,
|
|
@@ -67,7 +75,11 @@ const MODE_LABELS: Record<ShareMode, string> = {
|
|
|
67
75
|
// Screen component
|
|
68
76
|
// ---------------------------------------------------------------------------
|
|
69
77
|
|
|
70
|
-
export function SocialShareScreen(
|
|
78
|
+
export function SocialShareScreen({
|
|
79
|
+
allowInlineAuthorCreation = true,
|
|
80
|
+
}: {
|
|
81
|
+
allowInlineAuthorCreation?: boolean
|
|
82
|
+
}) {
|
|
71
83
|
const [state, dispatch] = useReducer(
|
|
72
84
|
shareFormReducer,
|
|
73
85
|
{ mode: 'author-social' as ShareMode },
|
|
@@ -78,6 +90,14 @@ export function SocialShareScreen() {
|
|
|
78
90
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
|
79
91
|
const [output, setOutput] = useState<ShareOutput | null>(null)
|
|
80
92
|
const [copied, setCopied] = useState(false)
|
|
93
|
+
const shareLinksData = useShareLinksData()
|
|
94
|
+
const authorRosterStatus = canonicalAuthorRosterStatus({
|
|
95
|
+
enabled: requiresCanonicalAuthorRoster(state.mode, allowInlineAuthorCreation),
|
|
96
|
+
author: state.author,
|
|
97
|
+
authors: shareLinksData.authors,
|
|
98
|
+
loading: shareLinksData.loading,
|
|
99
|
+
error: shareLinksData.error,
|
|
100
|
+
})
|
|
81
101
|
|
|
82
102
|
// Recent links — fetched from GET /api/share-links (same endpoint the pickers use).
|
|
83
103
|
// Used by the ShareStatsPanel for the Repoint affordance.
|
|
@@ -124,6 +144,11 @@ export function SocialShareScreen() {
|
|
|
124
144
|
setSubmitError(null)
|
|
125
145
|
setOutput(null)
|
|
126
146
|
|
|
147
|
+
if (authorRosterStatus.blocked) {
|
|
148
|
+
setSubmitError(authorRosterStatus.message)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
127
152
|
const result = buildBatchPayloadFrom(state)
|
|
128
153
|
if (!result.ok) {
|
|
129
154
|
setSubmitError(result.error)
|
|
@@ -152,7 +177,7 @@ export function SocialShareScreen() {
|
|
|
152
177
|
setSubmitting(false)
|
|
153
178
|
}
|
|
154
179
|
},
|
|
155
|
-
[state, goBase],
|
|
180
|
+
[state, goBase, authorRosterStatus],
|
|
156
181
|
)
|
|
157
182
|
|
|
158
183
|
const handleCopyAll = useCallback(() => {
|
|
@@ -208,7 +233,13 @@ export function SocialShareScreen() {
|
|
|
208
233
|
</div>
|
|
209
234
|
|
|
210
235
|
{/* Per-mode fields */}
|
|
211
|
-
<PerModeFields
|
|
236
|
+
<PerModeFields
|
|
237
|
+
state={state}
|
|
238
|
+
dispatch={dispatch}
|
|
239
|
+
fields={fields}
|
|
240
|
+
allowInlineAuthorCreation={allowInlineAuthorCreation}
|
|
241
|
+
shareLinksData={shareLinksData}
|
|
242
|
+
/>
|
|
212
243
|
|
|
213
244
|
{/* Advanced disclosure */}
|
|
214
245
|
<AdvancedSection state={state} dispatch={dispatch} fields={fields} />
|
|
@@ -231,7 +262,11 @@ export function SocialShareScreen() {
|
|
|
231
262
|
|
|
232
263
|
{/* Submit */}
|
|
233
264
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
234
|
-
<button
|
|
265
|
+
<button
|
|
266
|
+
type="submit"
|
|
267
|
+
className="btn primary"
|
|
268
|
+
disabled={submitting || authorRosterStatus.blocked}
|
|
269
|
+
>
|
|
235
270
|
<Icon name="share" size={15} />
|
|
236
271
|
{submitting ? 'Minting…' : 'Mint link(s)'}
|
|
237
272
|
</button>
|
|
@@ -281,10 +316,14 @@ function PerModeFields({
|
|
|
281
316
|
state,
|
|
282
317
|
dispatch,
|
|
283
318
|
fields,
|
|
319
|
+
allowInlineAuthorCreation,
|
|
320
|
+
shareLinksData,
|
|
284
321
|
}: {
|
|
285
322
|
state: ShareFormState
|
|
286
323
|
dispatch: React.Dispatch<Parameters<typeof shareFormReducer>[1]>
|
|
287
324
|
fields: string[]
|
|
325
|
+
allowInlineAuthorCreation: boolean
|
|
326
|
+
shareLinksData: ShareLinksData
|
|
288
327
|
}) {
|
|
289
328
|
const has = (f: string) => fields.includes(f)
|
|
290
329
|
|
|
@@ -374,7 +413,15 @@ function PerModeFields({
|
|
|
374
413
|
)}
|
|
375
414
|
|
|
376
415
|
{/* author ref — author-social (required), newsletter/youtube (optional) */}
|
|
377
|
-
{has('author') &&
|
|
416
|
+
{has('author') && (
|
|
417
|
+
<AuthorPicker
|
|
418
|
+
ref={state.author}
|
|
419
|
+
dispatch={dispatch}
|
|
420
|
+
mode={state.mode}
|
|
421
|
+
allowInlineAuthorCreation={allowInlineAuthorCreation}
|
|
422
|
+
shareLinksData={shareLinksData}
|
|
423
|
+
/>
|
|
424
|
+
)}
|
|
378
425
|
|
|
379
426
|
{/* handle ref — author-social, owned-social, newsletter, youtube */}
|
|
380
427
|
{has('handle') && <HandlePicker ref={state.handle} dispatch={dispatch} mode={state.mode} />}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
interface AuthorWriteFailureBody {
|
|
2
|
+
error?: string
|
|
3
|
+
errorClass?: string
|
|
4
|
+
author?: {
|
|
5
|
+
id?: string
|
|
6
|
+
slug?: string
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function formatAuthorWriteFailure(
|
|
11
|
+
body: AuthorWriteFailureBody | null,
|
|
12
|
+
httpFallback: string,
|
|
13
|
+
): string {
|
|
14
|
+
if (!body?.errorClass) return body?.error ?? httpFallback
|
|
15
|
+
|
|
16
|
+
const identity = [
|
|
17
|
+
body.author?.id ? `author id: ${body.author.id}` : '',
|
|
18
|
+
body.author?.slug ? `slug: ${body.author.slug}` : '',
|
|
19
|
+
].filter(Boolean)
|
|
20
|
+
const message = body.error || 'Author synchronization failed'
|
|
21
|
+
const classifiedMessage = message.includes(body.errorClass)
|
|
22
|
+
? message
|
|
23
|
+
: `${body.errorClass}: ${message}`
|
|
24
|
+
const containsIdentity =
|
|
25
|
+
!!body.author?.id &&
|
|
26
|
+
!!body.author?.slug &&
|
|
27
|
+
classifiedMessage.includes(body.author.id) &&
|
|
28
|
+
classifiedMessage.includes(body.author.slug)
|
|
29
|
+
return `${classifiedMessage}${
|
|
30
|
+
identity.length > 0 && !containsIdentity ? ` (${identity.join('; ')})` : ''
|
|
31
|
+
}`
|
|
32
|
+
}
|
|
@@ -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.
|
|
@@ -38,16 +39,31 @@ function CalendarRoute() {
|
|
|
38
39
|
return <CalendarScreen openDoc={() => navigate('content')} />
|
|
39
40
|
}
|
|
40
41
|
|
|
41
|
-
export
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
42
|
+
export function createCmsScreens({
|
|
43
|
+
allowInlineAuthorCreation = true,
|
|
44
|
+
surveyId,
|
|
45
|
+
}: {
|
|
46
|
+
allowInlineAuthorCreation?: boolean
|
|
47
|
+
surveyId?: string
|
|
48
|
+
} = {}): ScreenRegistry {
|
|
49
|
+
return {
|
|
50
|
+
content: () => <ContentRoute allowInlineAuthorCreation={allowInlineAuthorCreation} />,
|
|
51
|
+
'content-insights': () => <ContentInsightsScreen />,
|
|
52
|
+
dashboard: () => <DashboardRoute />,
|
|
53
|
+
calendar: () => <CalendarRoute />,
|
|
54
|
+
media: () => <MediaScreen />,
|
|
55
|
+
analytics: () => <AnalyticsScreen />,
|
|
56
|
+
authors: () => <AuthorsScreen />,
|
|
57
|
+
topics: () => <TopicsScreen />,
|
|
58
|
+
subscriptions: () => <SubscriptionsScreen />,
|
|
59
|
+
...(surveyId
|
|
60
|
+
? { 'survey-responses': () => <SurveyResponsesScreen surveyId={surveyId} /> }
|
|
61
|
+
: {}),
|
|
62
|
+
'social-share': () => (
|
|
63
|
+
<SocialShareScreen allowInlineAuthorCreation={allowInlineAuthorCreation} />
|
|
64
|
+
),
|
|
65
|
+
settings: () => <SettingsScreen />,
|
|
66
|
+
}
|
|
53
67
|
}
|
|
68
|
+
|
|
69
|
+
export const cmsScreens: ScreenRegistry = createCmsScreens()
|
|
@@ -22,6 +22,13 @@ export const SHARE_MODES = ['author-social', 'owned-social', 'youtube', 'newslet
|
|
|
22
22
|
|
|
23
23
|
export type ShareMode = (typeof SHARE_MODES)[number]
|
|
24
24
|
|
|
25
|
+
export function requiresCanonicalAuthorRoster(
|
|
26
|
+
mode: ShareMode,
|
|
27
|
+
allowInlineAuthorCreation: boolean,
|
|
28
|
+
): boolean {
|
|
29
|
+
return mode === 'author-social' && !allowInlineAuthorCreation
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
/**
|
|
26
33
|
* Social platform options.
|
|
27
34
|
* SOURCE OF TRUTH: @growth-labs/analytics src/utils/social.ts SOCIAL_PLATFORMS
|
|
@@ -91,6 +98,75 @@ export interface AuthorRef {
|
|
|
91
98
|
create?: boolean
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
export interface CanonicalAuthorEntry {
|
|
102
|
+
id: string
|
|
103
|
+
slug: string
|
|
104
|
+
displayName: string
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type CanonicalAuthorRosterStatus =
|
|
108
|
+
| { blocked: false; kind: 'not-required' | 'synchronized'; message: null }
|
|
109
|
+
| {
|
|
110
|
+
blocked: true
|
|
111
|
+
kind: 'loading' | 'missing-author' | 'out-of-sync' | 'unavailable'
|
|
112
|
+
message: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function canonicalAuthorRosterStatus({
|
|
116
|
+
enabled,
|
|
117
|
+
author,
|
|
118
|
+
authors,
|
|
119
|
+
loading,
|
|
120
|
+
error,
|
|
121
|
+
}: {
|
|
122
|
+
enabled: boolean
|
|
123
|
+
author: AuthorRef
|
|
124
|
+
authors: CanonicalAuthorEntry[]
|
|
125
|
+
loading: boolean
|
|
126
|
+
error: string | null
|
|
127
|
+
}): CanonicalAuthorRosterStatus {
|
|
128
|
+
if (!enabled) return { blocked: false, kind: 'not-required', message: null }
|
|
129
|
+
|
|
130
|
+
const id = author.id?.trim() ?? ''
|
|
131
|
+
const slug = author.slug?.trim().toLowerCase() ?? ''
|
|
132
|
+
const identity = id || slug
|
|
133
|
+
if (!identity) {
|
|
134
|
+
return {
|
|
135
|
+
blocked: true,
|
|
136
|
+
kind: 'missing-author',
|
|
137
|
+
message:
|
|
138
|
+
'No canonical author is linked to this content. Add or publish the author in the Authors screen, then retry.',
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (loading) {
|
|
142
|
+
return {
|
|
143
|
+
blocked: true,
|
|
144
|
+
kind: 'loading',
|
|
145
|
+
message: `Checking the synchronized author roster for canonical author ${identity}.`,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (error) {
|
|
149
|
+
return {
|
|
150
|
+
blocked: true,
|
|
151
|
+
kind: 'unavailable',
|
|
152
|
+
message: `The author roster could not be verified for canonical author ${identity}. Refresh and retry; sharing is blocked until synchronization can be proven.`,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const synchronized =
|
|
157
|
+
id !== ''
|
|
158
|
+
? authors.some((candidate) => candidate.id === id)
|
|
159
|
+
: authors.some((candidate) => candidate.slug.trim().toLowerCase() === slug)
|
|
160
|
+
if (!synchronized) {
|
|
161
|
+
return {
|
|
162
|
+
blocked: true,
|
|
163
|
+
kind: 'out-of-sync',
|
|
164
|
+
message: `Author roster out of sync for canonical author ${identity}. Publish or synchronize that author in the Authors screen, refresh, and retry.`,
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { blocked: false, kind: 'synchronized', message: null }
|
|
168
|
+
}
|
|
169
|
+
|
|
94
170
|
/** A reference to a social handle — existing by id/username or inline-created. */
|
|
95
171
|
export interface HandleRef {
|
|
96
172
|
id?: string
|
|
@@ -175,9 +251,7 @@ export interface ShareFormPrefill {
|
|
|
175
251
|
*/
|
|
176
252
|
export function initShareForm(mode: ShareMode, prefill?: ShareFormPrefill): ShareFormState {
|
|
177
253
|
const defaultCampaign =
|
|
178
|
-
mode === '
|
|
179
|
-
? prefill?.campaign?.trim() || prefill?.contentSlug?.trim() || ''
|
|
180
|
-
: ''
|
|
254
|
+
mode === 'owned-social' ? prefill?.campaign?.trim() || prefill?.contentSlug?.trim() || '' : ''
|
|
181
255
|
const base: ShareFormState = {
|
|
182
256
|
mode,
|
|
183
257
|
destinationUrl: prefill?.destinationUrl ?? '',
|
|
@@ -281,7 +355,12 @@ export function shareFormReducer(state: ShareFormState, action: ShareFormAction)
|
|
|
281
355
|
mode,
|
|
282
356
|
// Mode-specific resets
|
|
283
357
|
platform: mode === 'youtube' ? 'youtube' : mode === 'newsletter' ? '' : '',
|
|
284
|
-
campaign:
|
|
358
|
+
campaign:
|
|
359
|
+
mode === 'owned-social'
|
|
360
|
+
? state.campaign || state.contentSlug
|
|
361
|
+
: mode === 'author-social'
|
|
362
|
+
? ''
|
|
363
|
+
: '',
|
|
285
364
|
placements: mode === 'youtube' ? [...YOUTUBE_BUNDLE_PLACEMENTS] : [],
|
|
286
365
|
youtubeVideoId: mode === 'youtube' ? state.youtubeVideoId : '',
|
|
287
366
|
issue: mode === 'newsletter' ? state.issue : '',
|
|
@@ -289,15 +368,14 @@ export function shareFormReducer(state: ShareFormState, action: ShareFormAction)
|
|
|
289
368
|
}
|
|
290
369
|
|
|
291
370
|
case 'prefill-content': {
|
|
292
|
-
const shouldDefaultCampaign =
|
|
293
|
-
(state.mode === 'author-social' || state.mode === 'owned-social') && !state.campaign.trim()
|
|
371
|
+
const shouldDefaultCampaign = state.mode === 'owned-social' && !state.campaign.trim()
|
|
294
372
|
return {
|
|
295
373
|
...state,
|
|
296
374
|
destinationUrl: action.destinationUrl,
|
|
297
375
|
contentSlug: action.contentSlug,
|
|
298
376
|
contentType: action.contentType,
|
|
299
377
|
campaign: shouldDefaultCampaign ? action.contentSlug : state.campaign,
|
|
300
|
-
|
|
378
|
+
author: action.author ?? {},
|
|
301
379
|
}
|
|
302
380
|
}
|
|
303
381
|
|
|
@@ -349,7 +427,6 @@ export interface AuthorSocialBatchPayload {
|
|
|
349
427
|
kind: 'author-social'
|
|
350
428
|
destinationUrl: string
|
|
351
429
|
platform: string
|
|
352
|
-
campaign: string
|
|
353
430
|
placement?: string
|
|
354
431
|
boostMode?: string
|
|
355
432
|
promoCode?: string
|
|
@@ -462,14 +539,16 @@ export function buildBatchPayloadFrom(state: ShareFormState): BuildPayloadResult
|
|
|
462
539
|
|
|
463
540
|
switch (state.mode) {
|
|
464
541
|
case 'author-social': {
|
|
465
|
-
if (!state.
|
|
466
|
-
return {
|
|
542
|
+
if (!refHasContent(state.author)) {
|
|
543
|
+
return {
|
|
544
|
+
ok: false,
|
|
545
|
+
error: 'A canonical author is required for Author Social. Select a synchronized author.',
|
|
546
|
+
}
|
|
467
547
|
}
|
|
468
548
|
const payload: AuthorSocialBatchPayload = {
|
|
469
549
|
kind: 'author-social',
|
|
470
550
|
...shared,
|
|
471
551
|
platform: state.platform,
|
|
472
|
-
campaign: state.campaign.trim(),
|
|
473
552
|
...(state.placement && state.placement !== 'unknown' ? { placement: state.placement } : {}),
|
|
474
553
|
...(state.boostMode && state.boostMode !== 'none' ? { boostMode: state.boostMode } : {}),
|
|
475
554
|
...(nonEmpty(state.promoCode) ? { promoCode: state.promoCode } : {}),
|
|
@@ -701,7 +780,7 @@ export function toPlainText(output: ShareOutput): string {
|
|
|
701
780
|
* given mode. The React form renders only these inputs; the request builders
|
|
702
781
|
* read only these fields.
|
|
703
782
|
*
|
|
704
|
-
* 'author-social' →
|
|
783
|
+
* 'author-social' → platform, author, destinationUrl, advanced
|
|
705
784
|
* 'owned-social' → campaign, platform, handle, (optional author), destinationUrl, advanced
|
|
706
785
|
* 'youtube' → youtubeVideoId, placements, (optional handle/author), destinationUrl (NO campaign)
|
|
707
786
|
* 'newsletter' → issue, (optional author/handle), destinationUrl (NO campaign/platform/boostMode)
|
|
@@ -711,7 +790,7 @@ export function relevantFields(mode: ShareMode): string[] {
|
|
|
711
790
|
|
|
712
791
|
switch (mode) {
|
|
713
792
|
case 'author-social':
|
|
714
|
-
return [...shared, '
|
|
793
|
+
return [...shared, 'platform', 'author', 'placement', 'boostMode', 'promoCode']
|
|
715
794
|
|
|
716
795
|
case 'owned-social':
|
|
717
796
|
return [
|