@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.
Files changed (166) hide show
  1. package/README.md +55 -1
  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 +38 -0
  10. package/dist/integration/options.d.ts.map +1 -1
  11. package/dist/integration/options.js +13 -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/authors.d.ts.map +1 -1
  21. package/dist/routes/authors.js +33 -8
  22. package/dist/routes/authors.js.map +1 -1
  23. package/dist/routes/authz-matrix.d.ts.map +1 -1
  24. package/dist/routes/authz-matrix.js +2 -0
  25. package/dist/routes/authz-matrix.js.map +1 -1
  26. package/dist/routes/context.d.ts +1 -1
  27. package/dist/routes/context.d.ts.map +1 -1
  28. package/dist/routes/context.js.map +1 -1
  29. package/dist/routes/index.d.ts +1 -0
  30. package/dist/routes/index.d.ts.map +1 -1
  31. package/dist/routes/index.js +2 -0
  32. package/dist/routes/index.js.map +1 -1
  33. package/dist/routes/recovery-errors.d.ts +21 -0
  34. package/dist/routes/recovery-errors.d.ts.map +1 -0
  35. package/dist/routes/recovery-errors.js +53 -0
  36. package/dist/routes/recovery-errors.js.map +1 -0
  37. package/dist/routes/surveys.d.ts +12 -0
  38. package/dist/routes/surveys.d.ts.map +1 -0
  39. package/dist/routes/surveys.js +109 -0
  40. package/dist/routes/surveys.js.map +1 -0
  41. package/dist/schema/insights-ingest.d.ts +46 -46
  42. package/dist/surveys/access.d.ts +4 -0
  43. package/dist/surveys/access.d.ts.map +1 -0
  44. package/dist/surveys/access.js +9 -0
  45. package/dist/surveys/access.js.map +1 -0
  46. package/dist/surveys/errors.d.ts +43 -0
  47. package/dist/surveys/errors.d.ts.map +1 -0
  48. package/dist/surveys/errors.js +42 -0
  49. package/dist/surveys/errors.js.map +1 -0
  50. package/dist/surveys/schema.d.ts +939 -0
  51. package/dist/surveys/schema.d.ts.map +1 -0
  52. package/dist/surveys/schema.js +385 -0
  53. package/dist/surveys/schema.js.map +1 -0
  54. package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts +3 -0
  55. package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts.map +1 -0
  56. package/dist/ui/api/surveys/[surveyId]/responses.csv.js +17 -0
  57. package/dist/ui/api/surveys/[surveyId]/responses.csv.js.map +1 -0
  58. package/dist/ui/api/surveys/[surveyId].d.ts +3 -0
  59. package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -0
  60. package/dist/ui/api/surveys/[surveyId].js +17 -0
  61. package/dist/ui/api/surveys/[surveyId].js.map +1 -0
  62. package/dist/ui/client/mount.d.ts.map +1 -1
  63. package/dist/ui/client/mount.js +6 -2
  64. package/dist/ui/client/mount.js.map +1 -1
  65. package/dist/ui/commands.d.ts +5 -1
  66. package/dist/ui/commands.d.ts.map +1 -1
  67. package/dist/ui/commands.js +7 -0
  68. package/dist/ui/commands.js.map +1 -1
  69. package/dist/ui/components/CmsApp.d.ts +2 -0
  70. package/dist/ui/components/CmsApp.d.ts.map +1 -1
  71. package/dist/ui/components/CmsApp.js +14 -3
  72. package/dist/ui/components/CmsApp.js.map +1 -1
  73. package/dist/ui/components/CommandPalette.d.ts +2 -0
  74. package/dist/ui/components/CommandPalette.d.ts.map +1 -1
  75. package/dist/ui/components/CommandPalette.js +1 -1
  76. package/dist/ui/components/CommandPalette.js.map +1 -1
  77. package/dist/ui/components/ShareModal.d.ts +4 -1
  78. package/dist/ui/components/ShareModal.d.ts.map +1 -1
  79. package/dist/ui/components/ShareModal.js +39 -9
  80. package/dist/ui/components/ShareModal.js.map +1 -1
  81. package/dist/ui/components/SharePickers.d.ts +29 -2
  82. package/dist/ui/components/SharePickers.d.ts.map +1 -1
  83. package/dist/ui/components/SharePickers.js +33 -8
  84. package/dist/ui/components/SharePickers.js.map +1 -1
  85. package/dist/ui/components/Sidebar.d.ts +1 -0
  86. package/dist/ui/components/Sidebar.d.ts.map +1 -1
  87. package/dist/ui/components/Sidebar.js +1 -1
  88. package/dist/ui/components/Sidebar.js.map +1 -1
  89. package/dist/ui/hash-router.d.ts +1 -1
  90. package/dist/ui/hash-router.d.ts.map +1 -1
  91. package/dist/ui/hash-router.js +1 -0
  92. package/dist/ui/hash-router.js.map +1 -1
  93. package/dist/ui/nav.d.ts +5 -1
  94. package/dist/ui/nav.d.ts.map +1 -1
  95. package/dist/ui/nav.js +10 -2
  96. package/dist/ui/nav.js.map +1 -1
  97. package/dist/ui/screens/AuthorsScreen.d.ts.map +1 -1
  98. package/dist/ui/screens/AuthorsScreen.js +2 -1
  99. package/dist/ui/screens/AuthorsScreen.js.map +1 -1
  100. package/dist/ui/screens/ContentRoute.d.ts +3 -1
  101. package/dist/ui/screens/ContentRoute.d.ts.map +1 -1
  102. package/dist/ui/screens/ContentRoute.js +3 -3
  103. package/dist/ui/screens/ContentRoute.js.map +1 -1
  104. package/dist/ui/screens/EditorScreen.d.ts +2 -1
  105. package/dist/ui/screens/EditorScreen.d.ts.map +1 -1
  106. package/dist/ui/screens/EditorScreen.js +2 -2
  107. package/dist/ui/screens/EditorScreen.js.map +1 -1
  108. package/dist/ui/screens/LibraryScreen.d.ts +2 -1
  109. package/dist/ui/screens/LibraryScreen.d.ts.map +1 -1
  110. package/dist/ui/screens/LibraryScreen.js +2 -2
  111. package/dist/ui/screens/LibraryScreen.js.map +1 -1
  112. package/dist/ui/screens/SocialShareScreen.d.ts +3 -1
  113. package/dist/ui/screens/SocialShareScreen.d.ts.map +1 -1
  114. package/dist/ui/screens/SocialShareScreen.js +20 -8
  115. package/dist/ui/screens/SocialShareScreen.js.map +1 -1
  116. package/dist/ui/screens/SurveyResponsesScreen.d.ts +28 -0
  117. package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -0
  118. package/dist/ui/screens/SurveyResponsesScreen.js +110 -0
  119. package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -0
  120. package/dist/ui/screens/author-recovery.d.ts +11 -0
  121. package/dist/ui/screens/author-recovery.d.ts.map +1 -0
  122. package/dist/ui/screens/author-recovery.js +18 -0
  123. package/dist/ui/screens/author-recovery.js.map +1 -0
  124. package/dist/ui/screens/registry.d.ts +4 -0
  125. package/dist/ui/screens/registry.d.ts.map +1 -1
  126. package/dist/ui/screens/registry.js +20 -13
  127. package/dist/ui/screens/registry.js.map +1 -1
  128. package/dist/ui/screens/social-share-data.d.ts +23 -2
  129. package/dist/ui/screens/social-share-data.d.ts.map +1 -1
  130. package/dist/ui/screens/social-share-data.js +57 -11
  131. package/dist/ui/screens/social-share-data.js.map +1 -1
  132. package/package.json +1 -1
  133. package/src/index.ts +2 -2
  134. package/src/integration/index.ts +9 -0
  135. package/src/integration/options.ts +15 -0
  136. package/src/providers/null.ts +19 -0
  137. package/src/providers/types.ts +7 -0
  138. package/src/routes/authors.ts +41 -20
  139. package/src/routes/authz-matrix.ts +2 -0
  140. package/src/routes/context.ts +2 -0
  141. package/src/routes/index.ts +6 -0
  142. package/src/routes/recovery-errors.ts +70 -0
  143. package/src/routes/surveys.ts +130 -0
  144. package/src/surveys/access.ts +11 -0
  145. package/src/surveys/errors.ts +51 -0
  146. package/src/surveys/schema.ts +435 -0
  147. package/src/ui/api/surveys/[surveyId]/responses.csv.ts +23 -0
  148. package/src/ui/api/surveys/[surveyId].ts +23 -0
  149. package/src/ui/client/mount.tsx +6 -2
  150. package/src/ui/commands.ts +14 -1
  151. package/src/ui/components/CmsApp.tsx +25 -3
  152. package/src/ui/components/CommandPalette.tsx +2 -1
  153. package/src/ui/components/ShareModal.tsx +102 -19
  154. package/src/ui/components/SharePickers.tsx +76 -27
  155. package/src/ui/components/Sidebar.tsx +10 -2
  156. package/src/ui/hash-router.ts +1 -0
  157. package/src/ui/nav.ts +15 -2
  158. package/src/ui/screens/AuthorsScreen.tsx +7 -2
  159. package/src/ui/screens/ContentRoute.tsx +13 -3
  160. package/src/ui/screens/EditorScreen.tsx +7 -1
  161. package/src/ui/screens/LibraryScreen.tsx +3 -1
  162. package/src/ui/screens/SocialShareScreen.tsx +53 -6
  163. package/src/ui/screens/SurveyResponsesScreen.tsx +271 -0
  164. package/src/ui/screens/author-recovery.ts +32 -0
  165. package/src/ui/screens/registry.tsx +28 -12
  166. 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 { AuthorPicker, ContentPicker, HandlePicker } from '../components/SharePickers.js'
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 state={state} dispatch={dispatch} fields={fields} />
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 type="submit" className="btn primary" disabled={submitting}>
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') && <AuthorPicker ref={state.author} dispatch={dispatch} mode={state.mode} />}
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 const cmsScreens: ScreenRegistry = {
42
- content: () => <ContentRoute />,
43
- 'content-insights': () => <ContentInsightsScreen />,
44
- dashboard: () => <DashboardRoute />,
45
- calendar: () => <CalendarRoute />,
46
- media: () => <MediaScreen />,
47
- analytics: () => <AnalyticsScreen />,
48
- authors: () => <AuthorsScreen />,
49
- topics: () => <TopicsScreen />,
50
- subscriptions: () => <SubscriptionsScreen />,
51
- 'social-share': () => <SocialShareScreen />,
52
- settings: () => <SettingsScreen />,
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 === 'author-social' || mode === 'owned-social'
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: mode === 'youtube' || mode === 'newsletter' ? '' : state.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
- ...(action.author ? { author: action.author } : {}),
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.campaign.trim()) {
466
- return { ok: false, error: 'Campaign is required for author-social.' }
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' → campaign, platform, author, destinationUrl, advanced
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, 'campaign', 'platform', 'author', 'placement', 'boostMode', 'promoCode']
793
+ return [...shared, 'platform', 'author', 'placement', 'boostMode', 'promoCode']
715
794
 
716
795
  case 'owned-social':
717
796
  return [