@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
@@ -26,10 +26,12 @@ import {
26
26
  BOOST_MODES,
27
27
  type BoostMode,
28
28
  buildBatchPayloadFrom,
29
+ canonicalAuthorRosterStatus,
29
30
  formatShareOutput,
30
31
  initShareForm,
31
32
  type LinkResult,
32
33
  relevantFields,
34
+ requiresCanonicalAuthorRoster,
33
35
  SHARE_MODES,
34
36
  type ShareFormPrefill,
35
37
  type ShareMode,
@@ -41,6 +43,7 @@ import {
41
43
  toPlainText,
42
44
  YOUTUBE_BUNDLE_PLACEMENTS,
43
45
  } from '../screens/social-share-data.js'
46
+ import { type ShareLinksData, useShareLinksData } from './SharePickers.js'
44
47
 
45
48
  // ---------------------------------------------------------------------------
46
49
  // Props
@@ -62,6 +65,8 @@ export interface ShareModalPrefill {
62
65
  export interface ShareModalProps {
63
66
  prefill: ShareModalPrefill
64
67
  onClose: () => void
68
+ allowInlineAuthorCreation?: boolean
69
+ shareLinksData?: ShareLinksData
65
70
  }
66
71
 
67
72
  // ---------------------------------------------------------------------------
@@ -79,7 +84,12 @@ const MODE_LABELS: Record<ShareMode, string> = {
79
84
  // ShareModal
80
85
  // ---------------------------------------------------------------------------
81
86
 
82
- export function ShareModal({ prefill, onClose }: ShareModalProps) {
87
+ export function ShareModal({
88
+ prefill,
89
+ onClose,
90
+ allowInlineAuthorCreation = true,
91
+ shareLinksData: suppliedShareLinksData,
92
+ }: ShareModalProps) {
83
93
  const initialPrefill: ShareFormPrefill = {
84
94
  destinationUrl: prefill.destinationUrl,
85
95
  contentSlug: prefill.contentSlug,
@@ -97,6 +107,23 @@ export function ShareModal({ prefill, onClose }: ShareModalProps) {
97
107
  const [submitError, setSubmitError] = useState<string | null>(null)
98
108
  const [output, setOutput] = useState<ShareOutput | null>(null)
99
109
  const [copied, setCopied] = useState(false)
110
+ const shouldVerifyAuthorRoster = requiresCanonicalAuthorRoster(
111
+ state.mode,
112
+ allowInlineAuthorCreation,
113
+ )
114
+ const fetchedShareLinksData = useShareLinksData(
115
+ !suppliedShareLinksData && !allowInlineAuthorCreation,
116
+ )
117
+ const shareLinksData = suppliedShareLinksData ?? fetchedShareLinksData
118
+ const authorRosterStatus = canonicalAuthorRosterStatus({
119
+ enabled: shouldVerifyAuthorRoster,
120
+ author: state.author,
121
+ authors: shareLinksData.authors,
122
+ loading: shareLinksData.loading,
123
+ error: shareLinksData.error,
124
+ })
125
+ const authorRosterFailure =
126
+ authorRosterStatus.kind === 'out-of-sync' || authorRosterStatus.kind === 'unavailable'
100
127
 
101
128
  const goBase = typeof window !== 'undefined' ? `${window.location.origin}/go` : '/go'
102
129
 
@@ -114,6 +141,11 @@ export function ShareModal({ prefill, onClose }: ShareModalProps) {
114
141
  setSubmitError(null)
115
142
  setOutput(null)
116
143
 
144
+ if (authorRosterStatus.blocked) {
145
+ setSubmitError(authorRosterStatus.message)
146
+ return
147
+ }
148
+
117
149
  const result = buildBatchPayloadFrom(state)
118
150
  if (!result.ok) {
119
151
  setSubmitError(result.error)
@@ -142,7 +174,7 @@ export function ShareModal({ prefill, onClose }: ShareModalProps) {
142
174
  setSubmitting(false)
143
175
  }
144
176
  },
145
- [state, goBase],
177
+ [state, goBase, authorRosterStatus],
146
178
  )
147
179
 
148
180
  const handleCopyAll = useCallback(() => {
@@ -353,22 +385,69 @@ export function ShareModal({ prefill, onClose }: ShareModalProps) {
353
385
  {/* Author ref (inline — simplified for the modal; full picker in the screen) */}
354
386
  {fields.includes('author') && (
355
387
  <div style={fieldGroup}>
356
- <label htmlFor="sm-author" className="field-label">
357
- Author display name
358
- </label>
359
- <input
360
- id="sm-author"
361
- type="text"
362
- className="input"
363
- value={state.author.displayName ?? ''}
364
- onChange={(e) =>
365
- dispatch({
366
- type: 'set-author',
367
- ref: { ...state.author, displayName: e.currentTarget.value, create: true },
368
- })
369
- }
370
- placeholder="Jane Smith"
371
- />
388
+ {allowInlineAuthorCreation ? (
389
+ <>
390
+ <label htmlFor="sm-author" className="field-label">
391
+ Author display name
392
+ </label>
393
+ <input
394
+ id="sm-author"
395
+ type="text"
396
+ className="input"
397
+ value={state.author.displayName ?? ''}
398
+ onChange={(e) =>
399
+ dispatch({
400
+ type: 'set-author',
401
+ ref: {
402
+ ...state.author,
403
+ displayName: e.currentTarget.value,
404
+ create: true,
405
+ },
406
+ })
407
+ }
408
+ placeholder="Jane Smith"
409
+ />
410
+ </>
411
+ ) : authorRosterFailure ? (
412
+ <div
413
+ role="alert"
414
+ style={{
415
+ padding: '10px 12px',
416
+ border: '1px solid color-mix(in srgb, var(--red) 25%, transparent)',
417
+ borderRadius: 'var(--radius-sm)',
418
+ color: 'var(--red)',
419
+ }}
420
+ >
421
+ {authorRosterStatus.message}
422
+ </div>
423
+ ) : state.author.id || state.author.slug || state.author.displayName ? (
424
+ <>
425
+ <div className="field-label">Canonical author</div>
426
+ <div className="input">
427
+ {state.author.displayName || state.author.slug || state.author.id}
428
+ </div>
429
+ {authorRosterStatus.kind === 'loading' && (
430
+ <div className="help" style={{ marginTop: 6 }}>
431
+ {authorRosterStatus.message}
432
+ </div>
433
+ )}
434
+ </>
435
+ ) : state.mode === 'author-social' ? (
436
+ <div
437
+ role="alert"
438
+ style={{
439
+ padding: '10px 12px',
440
+ border: '1px solid color-mix(in srgb, var(--red) 25%, transparent)',
441
+ borderRadius: 'var(--radius-sm)',
442
+ color: 'var(--red)',
443
+ }}
444
+ >
445
+ No canonical author is linked to this content. Add or publish the author in the
446
+ Authors screen, then retry.
447
+ </div>
448
+ ) : (
449
+ <div className="help">No author attribution selected (optional).</div>
450
+ )}
372
451
  </div>
373
452
  )}
374
453
 
@@ -514,7 +593,11 @@ export function ShareModal({ prefill, onClose }: ShareModalProps) {
514
593
  <button type="button" className="btn" onClick={onClose}>
515
594
  Cancel
516
595
  </button>
517
- <button type="submit" className="btn primary" disabled={submitting}>
596
+ <button
597
+ type="submit"
598
+ className="btn primary"
599
+ disabled={submitting || authorRosterStatus.blocked}
600
+ >
518
601
  <Icon name="share" size={14} />
519
602
  {submitting ? 'Minting…' : 'Mint link(s)'}
520
603
  </button>
@@ -27,11 +27,14 @@
27
27
  import { useCallback, useEffect, useRef, useState } from 'react'
28
28
  import { Icon } from '../icons.js'
29
29
  import { publicUrlFor } from '../screens/public-url.js'
30
- import type {
31
- AuthorRef,
32
- HandleRef,
33
- ShareFormAction,
34
- ShareMode,
30
+ import {
31
+ type AuthorRef,
32
+ type CanonicalAuthorEntry,
33
+ canonicalAuthorRosterStatus,
34
+ type HandleRef,
35
+ requiresCanonicalAuthorRoster,
36
+ type ShareFormAction,
37
+ type ShareMode,
35
38
  } from '../screens/social-share-data.js'
36
39
 
37
40
  // ---------------------------------------------------------------------------
@@ -39,11 +42,7 @@ import type {
39
42
  // SOURCE OF TRUTH: @growth-labs/analytics src/routes/share-links.ts GET response
40
43
  // ---------------------------------------------------------------------------
41
44
 
42
- interface AuthorEntry {
43
- id: string
44
- slug: string
45
- displayName: string
46
- }
45
+ export interface AuthorEntry extends CanonicalAuthorEntry {}
47
46
 
48
47
  interface HandleEntry {
49
48
  id: string
@@ -60,6 +59,13 @@ interface ShareLinksListResponse {
60
59
  links: unknown[]
61
60
  }
62
61
 
62
+ export interface ShareLinksData {
63
+ authors: AuthorEntry[]
64
+ handles: HandleEntry[]
65
+ loading: boolean
66
+ error: string | null
67
+ }
68
+
63
69
  // LibraryRow subset — only what the content picker needs.
64
70
  // SOURCE OF TRUTH: packages/cms/src/ui/screens/library-data.ts LibraryRow
65
71
  interface ContentRow {
@@ -322,20 +328,21 @@ export function ContentPicker({ siteBase, dispatch }: ContentPickerProps) {
322
328
  * so the component is self-contained; in practice the two fetches are identical
323
329
  * and the browser deduplicates via HTTP cache / same-tick coalescing).
324
330
  */
325
- function useShareLinksData(): {
326
- authors: AuthorEntry[]
327
- handles: HandleEntry[]
328
- loading: boolean
329
- error: string | null
330
- } {
331
+ export function useShareLinksData(enabled = true): ShareLinksData {
331
332
  const [authors, setAuthors] = useState<AuthorEntry[]>([])
332
333
  const [handles, setHandles] = useState<HandleEntry[]>([])
333
- const [loading, setLoading] = useState(true)
334
+ const [loading, setLoading] = useState(enabled)
334
335
  const [error, setError] = useState<string | null>(null)
335
336
 
336
337
  useEffect(() => {
338
+ if (!enabled) {
339
+ setLoading(false)
340
+ return
341
+ }
337
342
  let cancelled = false
338
343
  async function load() {
344
+ setLoading(true)
345
+ setError(null)
339
346
  try {
340
347
  const res = await fetch('/api/share-links')
341
348
  if (!res.ok) {
@@ -357,7 +364,7 @@ function useShareLinksData(): {
357
364
  return () => {
358
365
  cancelled = true
359
366
  }
360
- }, [])
367
+ }, [enabled])
361
368
 
362
369
  return { authors, handles, loading, error }
363
370
  }
@@ -418,6 +425,8 @@ export interface AuthorPickerProps {
418
425
  ref: AuthorRef
419
426
  dispatch: React.Dispatch<ShareFormAction>
420
427
  mode: ShareMode
428
+ allowInlineAuthorCreation?: boolean
429
+ shareLinksData?: ShareLinksData
421
430
  }
422
431
 
423
432
  /**
@@ -425,8 +434,15 @@ export interface AuthorPickerProps {
425
434
  * Supports inline-create via `create:true` + `displayName`.
426
435
  * Required for author-social; optional for newsletter/youtube.
427
436
  */
428
- export function AuthorPicker({ ref: authorRef, dispatch, mode }: AuthorPickerProps) {
429
- const { authors, loading, error } = useShareLinksData()
437
+ export function AuthorPicker({
438
+ ref: authorRef,
439
+ dispatch,
440
+ mode,
441
+ allowInlineAuthorCreation = true,
442
+ shareLinksData,
443
+ }: AuthorPickerProps) {
444
+ const fetchedShareLinksData = useShareLinksData(!shareLinksData)
445
+ const { authors, loading, error } = shareLinksData ?? fetchedShareLinksData
430
446
  const isRequired = mode === 'author-social'
431
447
  const [addNew, setAddNew] = useState(false)
432
448
  const [newName, setNewName] = useState('')
@@ -434,7 +450,19 @@ export function AuthorPicker({ ref: authorRef, dispatch, mode }: AuthorPickerPro
434
450
  // If an existing author ref (with id/slug) is set, show a chip
435
451
  const selectedExisting = authorRef.id
436
452
  ? (authors.find((a) => a.id === authorRef.id) ?? null)
437
- : null
453
+ : authorRef.slug
454
+ ? (authors.find(
455
+ (a) => a.slug.trim().toLowerCase() === authorRef.slug?.trim().toLowerCase(),
456
+ ) ?? null)
457
+ : null
458
+ const rosterStatus = canonicalAuthorRosterStatus({
459
+ enabled: requiresCanonicalAuthorRoster(mode, allowInlineAuthorCreation),
460
+ author: authorRef,
461
+ authors,
462
+ loading,
463
+ error,
464
+ })
465
+ const rosterDrift = rosterStatus.kind === 'out-of-sync' || rosterStatus.kind === 'unavailable'
438
466
 
439
467
  const handleSelectExisting = (author: AuthorEntry) => {
440
468
  dispatch({
@@ -465,8 +493,22 @@ export function AuthorPicker({ ref: authorRef, dispatch, mode }: AuthorPickerPro
465
493
  <div>
466
494
  <FieldLabel required={isRequired}>{isRequired ? 'Author' : 'Author (optional)'}</FieldLabel>
467
495
 
496
+ {rosterDrift && (
497
+ <div
498
+ role="alert"
499
+ style={{
500
+ padding: '10px 12px',
501
+ border: '1px solid color-mix(in srgb, var(--red) 25%, transparent)',
502
+ borderRadius: 'var(--radius-sm)',
503
+ color: 'var(--red)',
504
+ }}
505
+ >
506
+ {rosterStatus.message}
507
+ </div>
508
+ )}
509
+
468
510
  {/* Show current selection as a chip */}
469
- {displayValue ? (
511
+ {!rosterDrift && displayValue ? (
470
512
  <SelectionChip
471
513
  icon="users"
472
514
  label={displayValue}
@@ -474,7 +516,7 @@ export function AuthorPicker({ ref: authorRef, dispatch, mode }: AuthorPickerPro
474
516
  onClear={handleClear}
475
517
  clearTitle="Clear author"
476
518
  />
477
- ) : (
519
+ ) : !rosterDrift ? (
478
520
  <>
479
521
  {/* Existing authors list */}
480
522
  {loading && (
@@ -521,12 +563,14 @@ export function AuthorPicker({ ref: authorRef, dispatch, mode }: AuthorPickerPro
521
563
  className="panel panel-pad help"
522
564
  style={{ margin: '0 0 8px', padding: '10px 12px' }}
523
565
  >
524
- No authors yet — add one below.
566
+ {allowInlineAuthorCreation
567
+ ? 'No authors yet — add one below.'
568
+ : 'No synchronized authors are available. Create and publish the author in the canonical Authors screen, then retry.'}
525
569
  </div>
526
570
  )}
527
571
 
528
572
  {/* Inline add new */}
529
- {addNew ? (
573
+ {allowInlineAuthorCreation && addNew ? (
530
574
  <div style={{ display: 'flex', gap: 8 }}>
531
575
  <input
532
576
  type="text"
@@ -555,7 +599,7 @@ export function AuthorPicker({ ref: authorRef, dispatch, mode }: AuthorPickerPro
555
599
  <Icon name="x" size={13} />
556
600
  </button>
557
601
  </div>
558
- ) : (
602
+ ) : allowInlineAuthorCreation ? (
559
603
  <button
560
604
  type="button"
561
605
  className="btn sm ghost"
@@ -565,8 +609,13 @@ export function AuthorPicker({ ref: authorRef, dispatch, mode }: AuthorPickerPro
565
609
  <Icon name="plus" size={13} />
566
610
  Add new author
567
611
  </button>
568
- )}
612
+ ) : null}
569
613
  </>
614
+ ) : null}
615
+ {!rosterDrift && rosterStatus.kind === 'loading' && (
616
+ <div className="help" style={{ margin: '6px 0 0' }}>
617
+ {rosterStatus.message}
618
+ </div>
570
619
  )}
571
620
  </div>
572
621
  )
@@ -5,10 +5,18 @@ import { navItems } from '../nav.js'
5
5
  import { useWorkspace } from '../workspace-context.js'
6
6
  import { SiteSwitcher } from './SiteSwitcher.js'
7
7
 
8
- export function Sidebar(props: { route: Route; navigate: (r: Route) => void; collapsed: boolean }) {
8
+ export function Sidebar(props: {
9
+ route: Route
10
+ navigate: (r: Route) => void
11
+ collapsed: boolean
12
+ surveyResultsEnabled?: boolean
13
+ }) {
9
14
  const { state } = useWorkspace()
10
- const groups = navItems()
11
15
  const user = state.user
16
+ const groups = navItems(
17
+ {},
18
+ { surveyResultsEnabled: props.surveyResultsEnabled, role: user?.role },
19
+ )
12
20
  const initial = user?.name?.[0]?.toUpperCase() ?? user?.email?.[0]?.toUpperCase() ?? '?'
13
21
 
14
22
  return (
@@ -11,6 +11,7 @@ export const ROUTES = [
11
11
  'topics',
12
12
  'content-insights',
13
13
  'subscriptions',
14
+ 'survey-responses',
14
15
  'social-share',
15
16
  'settings',
16
17
  ] as const
package/src/ui/nav.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // src/ui/nav.ts — the Masthead sidebar nav model. Two groups, 11 items
2
2
  // (spec §6: Social Share added between Subscriptions and Settings).
3
3
 
4
+ import type { CmsRole } from '../schema/types.js'
5
+ import { canViewSurveyResponses } from '../surveys/access.js'
4
6
  import type { Route } from './hash-router.js'
5
7
  import type { IconName } from './icons.js'
6
8
 
@@ -33,6 +35,7 @@ export const NAV_MODEL: NavGroup[] = [
33
35
  { id: 'topics', label: 'Topics & tags', icon: 'tag' },
34
36
  { id: 'content-insights', label: 'Content Insights', icon: 'sparkle' },
35
37
  { id: 'subscriptions', label: 'Subscriptions', icon: 'inbox' },
38
+ { id: 'survey-responses', label: 'Survey responses', icon: 'chart' },
36
39
  { id: 'social-share', label: 'Social Share', icon: 'share' },
37
40
  { id: 'settings', label: 'Settings', icon: 'settings' },
38
41
  ],
@@ -40,9 +43,19 @@ export const NAV_MODEL: NavGroup[] = [
40
43
  ]
41
44
 
42
45
  /** Build the nav with live counts merged onto matching item ids. */
43
- export function navItems(counts: Partial<Record<Route, number>> = {}): NavGroup[] {
46
+ export function navItems(
47
+ counts: Partial<Record<Route, number>> = {},
48
+ options: { surveyResultsEnabled?: boolean; role?: CmsRole | null } = {
49
+ surveyResultsEnabled: true,
50
+ role: 'owner',
51
+ },
52
+ ): NavGroup[] {
53
+ const canViewSurvey =
54
+ options.surveyResultsEnabled !== false && canViewSurveyResponses(options.role)
44
55
  return NAV_MODEL.map((g) => ({
45
56
  group: g.group,
46
- items: g.items.map((i) => (counts[i.id] != null ? { ...i, count: counts[i.id] } : { ...i })),
57
+ items: g.items
58
+ .filter((item) => item.id !== 'survey-responses' || canViewSurvey)
59
+ .map((i) => (counts[i.id] != null ? { ...i, count: counts[i.id] } : { ...i })),
47
60
  }))
48
61
  }
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { useCallback, useEffect, useReducer, useState } from 'react'
24
24
  import { Icon } from '../icons.js'
25
+ import { formatAuthorWriteFailure } from './author-recovery.js'
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // Data shape — mirrors serializeAuthor() in src/routes/authors.ts
@@ -182,11 +183,15 @@ export function AuthorsScreen() {
182
183
  })
183
184
 
184
185
  if (!res.ok) {
185
- const detail = (await res.json().catch(() => null)) as { error?: string } | null
186
+ const detail = (await res.json().catch(() => null)) as {
187
+ error?: string
188
+ errorClass?: string
189
+ author?: { id?: string; slug?: string }
190
+ } | null
186
191
  setEditor((e) => ({
187
192
  ...e,
188
193
  submitting: false,
189
- error: detail?.error ?? `${res.status} ${res.statusText}`,
194
+ error: formatAuthorWriteFailure(detail, `${res.status} ${res.statusText}`),
190
195
  }))
191
196
  return
192
197
  }
@@ -12,7 +12,11 @@ import { type ContentType, contentViewReducer, initialContentView } from './cont
12
12
  import { EditorScreen } from './EditorScreen.js'
13
13
  import { LibraryScreen } from './LibraryScreen.js'
14
14
 
15
- export function ContentRoute() {
15
+ export function ContentRoute({
16
+ allowInlineAuthorCreation = true,
17
+ }: {
18
+ allowInlineAuthorCreation?: boolean
19
+ }) {
16
20
  const [view, dispatch] = useReducer(contentViewReducer, initialContentView)
17
21
 
18
22
  function openDoc(id: string | null, contentType?: ContentType) {
@@ -28,8 +32,14 @@ export function ContentRoute() {
28
32
  }
29
33
 
30
34
  if (view.open === null) {
31
- return <LibraryScreen openDoc={openDoc} />
35
+ return <LibraryScreen openDoc={openDoc} allowInlineAuthorCreation={allowInlineAuthorCreation} />
32
36
  }
33
37
 
34
- return <EditorScreen docRef={view.open} onClose={handleClose} />
38
+ return (
39
+ <EditorScreen
40
+ docRef={view.open}
41
+ onClose={handleClose}
42
+ allowInlineAuthorCreation={allowInlineAuthorCreation}
43
+ />
44
+ )
35
45
  }
@@ -38,6 +38,7 @@ import type { ContentView } from './content-view.js'
38
38
  export interface EditorScreenProps {
39
39
  docRef: NonNullable<ContentView['open']>
40
40
  onClose: () => void
41
+ allowInlineAuthorCreation?: boolean
41
42
  }
42
43
 
43
44
  // ---------------------------------------------------------------------------
@@ -66,7 +67,11 @@ const STATUS_PILL_COLORS: Record<ContentStatus, string> = {
66
67
  // EditorScreen
67
68
  // ---------------------------------------------------------------------------
68
69
 
69
- export function EditorScreen({ docRef, onClose }: EditorScreenProps) {
70
+ export function EditorScreen({
71
+ docRef,
72
+ onClose,
73
+ allowInlineAuthorCreation = true,
74
+ }: EditorScreenProps) {
70
75
  const contentType: ContentType = (docRef.contentType as ContentType | undefined) ?? 'article'
71
76
  const siteBase = typeof window !== 'undefined' ? window.location.origin : undefined
72
77
 
@@ -397,6 +402,7 @@ export function EditorScreen({ docRef, onClose }: EditorScreenProps) {
397
402
  },
398
403
  siteBase,
399
404
  )}
405
+ allowInlineAuthorCreation={allowInlineAuthorCreation}
400
406
  onClose={() => setShareOpen(false)}
401
407
  />
402
408
  )}
@@ -29,6 +29,7 @@ import {
29
29
 
30
30
  export interface LibraryScreenProps {
31
31
  openDoc: (id: string | null, contentType?: ContentType) => void
32
+ allowInlineAuthorCreation?: boolean
32
33
  }
33
34
 
34
35
  // ---------------------------------------------------------------------------
@@ -198,7 +199,7 @@ function getVisibleRows(state: LibraryState): LibraryRow[] {
198
199
  // Main component
199
200
  // ---------------------------------------------------------------------------
200
201
 
201
- export function LibraryScreen({ openDoc }: LibraryScreenProps) {
202
+ export function LibraryScreen({ openDoc, allowInlineAuthorCreation = true }: LibraryScreenProps) {
202
203
  const { state: ws } = useWorkspace()
203
204
  const siteBase = typeof window !== 'undefined' ? window.location.origin : undefined
204
205
  const [lib, dispatch] = useReducer(libraryReducer, undefined, initLibrary)
@@ -487,6 +488,7 @@ export function LibraryScreen({ openDoc }: LibraryScreenProps) {
487
488
  {lib.shareRow && (
488
489
  <ShareModal
489
490
  prefill={buildSharePrefill(lib.shareRow, siteBase)}
491
+ allowInlineAuthorCreation={allowInlineAuthorCreation}
490
492
  onClose={() => dispatch({ type: 'close-share' })}
491
493
  />
492
494
  )}