@prenta/admin 1.5.1 → 1.6.0

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 (75) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/__tests__/components/request-changes-dialog.render.test.d.ts +2 -0
  3. package/dist/__tests__/components/request-changes-dialog.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/request-changes-dialog.render.test.js +12 -0
  5. package/dist/__tests__/components/request-changes-dialog.render.test.js.map +1 -0
  6. package/dist/__tests__/lib/page-editor-service-api.test.js +28 -15
  7. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
  8. package/dist/__tests__/lib/post-editor-service-api.test.d.ts +2 -0
  9. package/dist/__tests__/lib/post-editor-service-api.test.d.ts.map +1 -0
  10. package/dist/__tests__/lib/post-editor-service-api.test.js +67 -0
  11. package/dist/__tests__/lib/post-editor-service-api.test.js.map +1 -0
  12. package/dist/__tests__/lib/post-editor-service.test.js +2 -0
  13. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  14. package/dist/__tests__/lib/workflow-chrome.test.d.ts +2 -0
  15. package/dist/__tests__/lib/workflow-chrome.test.d.ts.map +1 -0
  16. package/dist/__tests__/lib/workflow-chrome.test.js +108 -0
  17. package/dist/__tests__/lib/workflow-chrome.test.js.map +1 -0
  18. package/dist/__tests__/views/editor-top-bar.render.test.js +40 -33
  19. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  20. package/dist/__tests__/views/page-section-editor.test.js +8 -0
  21. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  22. package/dist/__tests__/views/page-tab.render.test.js +2 -0
  23. package/dist/__tests__/views/page-tab.render.test.js.map +1 -1
  24. package/dist/__tests__/views/post-fields-modal.render.test.js +2 -0
  25. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  26. package/dist/__tests__/views/post-section-editor.test.js +2 -0
  27. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  28. package/dist/__tests__/views/post-tab.render.test.js +2 -0
  29. package/dist/__tests__/views/post-tab.render.test.js.map +1 -1
  30. package/dist/components/RequestChangesDialog.d.ts +6 -0
  31. package/dist/components/RequestChangesDialog.d.ts.map +1 -0
  32. package/dist/components/RequestChangesDialog.js +15 -0
  33. package/dist/components/RequestChangesDialog.js.map +1 -0
  34. package/dist/lib/page-editor-service.d.ts +6 -2
  35. package/dist/lib/page-editor-service.d.ts.map +1 -1
  36. package/dist/lib/page-editor-service.js +16 -12
  37. package/dist/lib/page-editor-service.js.map +1 -1
  38. package/dist/lib/post-editor-service.d.ts +6 -1
  39. package/dist/lib/post-editor-service.d.ts.map +1 -1
  40. package/dist/lib/post-editor-service.js +16 -11
  41. package/dist/lib/post-editor-service.js.map +1 -1
  42. package/dist/lib/workflow-chrome.d.ts +22 -0
  43. package/dist/lib/workflow-chrome.d.ts.map +1 -0
  44. package/dist/lib/workflow-chrome.js +80 -0
  45. package/dist/lib/workflow-chrome.js.map +1 -0
  46. package/dist/prenta-admin.css +1 -1
  47. package/dist/views/page-editor/EditorTopBar.d.ts +10 -5
  48. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  49. package/dist/views/page-editor/EditorTopBar.js +38 -28
  50. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  51. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  52. package/dist/views/page-editor/PageSectionEditor.js +83 -62
  53. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  54. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  55. package/dist/views/post-editor/PostSectionEditor.js +78 -62
  56. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  57. package/package.json +3 -3
  58. package/src/__tests__/components/request-changes-dialog.render.test.tsx +11 -0
  59. package/src/__tests__/lib/page-editor-service-api.test.ts +37 -18
  60. package/src/__tests__/lib/post-editor-service-api.test.ts +73 -0
  61. package/src/__tests__/lib/post-editor-service.test.ts +2 -0
  62. package/src/__tests__/lib/workflow-chrome.test.ts +125 -0
  63. package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -45
  64. package/src/__tests__/views/page-section-editor.test.tsx +18 -0
  65. package/src/__tests__/views/page-tab.render.test.tsx +2 -0
  66. package/src/__tests__/views/post-fields-modal.render.test.tsx +2 -0
  67. package/src/__tests__/views/post-section-editor.test.tsx +2 -0
  68. package/src/__tests__/views/post-tab.render.test.tsx +2 -0
  69. package/src/components/RequestChangesDialog.tsx +56 -0
  70. package/src/lib/page-editor-service.ts +26 -11
  71. package/src/lib/post-editor-service.ts +26 -10
  72. package/src/lib/workflow-chrome.ts +105 -0
  73. package/src/views/page-editor/EditorTopBar.tsx +110 -48
  74. package/src/views/page-editor/PageSectionEditor.tsx +121 -92
  75. package/src/views/post-editor/PostSectionEditor.tsx +111 -88
@@ -1,5 +1,6 @@
1
1
  'use client'
2
2
 
3
+ import { useState, type ReactNode } from 'react'
3
4
  import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
4
5
  import {
5
6
  Calendar,
@@ -14,11 +15,20 @@ import {
14
15
  } from 'lucide-react'
15
16
  import { EditorSeoScoreBadge } from '../../components/seo/EditorSeoScoreBadge.js'
16
17
  import { BreakpointPicker } from '../../components/BreakpointPicker.js'
18
+ import { RequestChangesDialog } from '../../components/RequestChangesDialog.js'
17
19
  import { ZoomChip } from '../../components/ZoomChip.js'
18
20
  import { adminPortalContainer } from '../../lib/portal-container.js'
19
21
  import type { PageStatus } from '../../lib/page-editor-service.js'
20
22
  import type { Zoom } from '../../lib/breakpoints.js'
21
23
  import { resolveViewSiteChrome, type ViewSiteIntent } from '../../lib/view-site-chrome.js'
24
+ import {
25
+ APPROVE_LABEL,
26
+ REQUEST_CHANGES_LABEL,
27
+ REVIEWER_NOTE_PREFIX,
28
+ SUBMIT_FOR_REVIEW_LABEL,
29
+ resolveWorkflowChrome,
30
+ type WorkflowStage,
31
+ } from '../../lib/workflow-chrome.js'
22
32
  import { type ViewportId } from './viewports.js'
23
33
 
24
34
  export type SaveState = 'idle' | 'saving' | 'saved' | 'error'
@@ -26,14 +36,16 @@ export type SaveState = 'idle' | 'saving' | 'saved' | 'error'
26
36
  interface EditorTopBarProps {
27
37
  title: string
28
38
  status: PageStatus
39
+ workflowStage: WorkflowStage
40
+ reviewNote?: string | null
41
+ role: string
29
42
  dirty: boolean
30
43
  saveState: SaveState
31
44
  publishing: boolean
32
45
  canEdit: boolean
33
- canPublish: boolean
34
- /** When false, Publish stays disabled even if the user has publish permission. */
46
+ /** When false, Approve stays disabled even if the user can review. */
35
47
  publishReady?: boolean
36
- /** Shown on hover/focus when publish is blocked by validation (not role). */
48
+ /** Shown on hover/focus when approve is blocked by validation (not role). */
37
49
  publishDisabledReason?: string
38
50
  viewport: ViewportId
39
51
  /** Canvas zoom mode; omit to hide the zoom chip. */
@@ -82,7 +94,9 @@ interface EditorTopBarProps {
82
94
  /** Band for `seoScore`; omit to derive from the content-analysis scale. */
83
95
  seoScoreBand?: 'good' | 'fair' | 'poor'
84
96
  onSaveDraft: () => void
85
- onPublish: () => void
97
+ onSubmitForReview: () => void
98
+ onApprove: () => void
99
+ onRequestChanges: (note: string) => void
86
100
  /** Open the schedule-publish dialog. Omitted when the document is unsaved. */
87
101
  onSchedule?: () => void
88
102
  /** When false, Schedule stays disabled (e.g. unsaved document). Defaults to true. */
@@ -97,21 +111,16 @@ interface EditorTopBarProps {
97
111
  structuralTypes?: string[]
98
112
  }
99
113
 
100
- const STATUS_TEXT: Record<PageStatus, { label: string; className: string }> = {
101
- DRAFT: { label: 'Draft', className: 'text-warning dark:text-amber-400' },
102
- PUBLISHED: { label: 'Published', className: 'text-emerald-600 dark:text-emerald-400' },
103
- SCHEDULED: { label: 'Scheduled', className: 'text-warning dark:text-amber-400' },
104
- ARCHIVED: { label: 'Archived', className: 'text-muted-foreground' },
105
- }
106
-
107
114
  export function EditorTopBar({
108
115
  title,
109
116
  status,
117
+ workflowStage,
118
+ reviewNote,
119
+ role,
110
120
  dirty,
111
121
  saveState,
112
122
  publishing,
113
123
  canEdit,
114
- canPublish,
115
124
  publishReady = true,
116
125
  publishDisabledReason,
117
126
  viewport,
@@ -133,13 +142,16 @@ export function EditorTopBar({
133
142
  seoScore,
134
143
  seoScoreBand,
135
144
  onSaveDraft,
136
- onPublish,
145
+ onSubmitForReview,
146
+ onApprove,
147
+ onRequestChanges,
137
148
  onSchedule,
138
149
  scheduleReady = true,
139
150
  scheduledAt = null,
140
151
  scheduledUnpublishAt = null,
141
152
  }: EditorTopBarProps) {
142
- const statusInfo = STATUS_TEXT[status]
153
+ const [requestChangesOpen, setRequestChangesOpen] = useState(false)
154
+ const workflow = resolveWorkflowChrome({ stage: workflowStage, status, role, canEdit })
143
155
  const chrome = onViewOnSite
144
156
  ? resolveViewSiteChrome({
145
157
  status,
@@ -150,36 +162,74 @@ export function EditorTopBar({
150
162
  })
151
163
  : null
152
164
 
153
- const publishBlocked = !canPublish || !publishReady || publishing || saveState === 'saving'
154
- const publishTitle = publishing
165
+ const approveBlocked = !publishReady || publishing || saveState === 'saving'
166
+ const approveTitle = publishing
155
167
  ? 'Publishing…'
156
168
  : saveState === 'saving'
157
169
  ? 'Wait for the current save to finish'
158
- : canPublish && !publishReady && publishDisabledReason
170
+ : !publishReady && publishDisabledReason
159
171
  ? publishDisabledReason
160
- : !canPublish
161
- ? 'You do not have permission to publish'
162
- : undefined
172
+ : undefined
163
173
 
164
- const scheduleBlocked =
165
- !onSchedule ||
166
- !canPublish ||
167
- !publishReady ||
168
- !scheduleReady ||
169
- publishing ||
170
- saveState === 'saving'
174
+ const scheduleBlocked = !publishReady || !scheduleReady || publishing || saveState === 'saving'
171
175
  const scheduleTitle =
172
176
  saveState === 'saving'
173
177
  ? 'Wait for the current save to finish'
174
178
  : !scheduleReady
175
179
  ? 'Save before scheduling'
176
- : canPublish && !publishReady && publishDisabledReason
180
+ : !publishReady && publishDisabledReason
177
181
  ? publishDisabledReason
178
- : !canPublish
179
- ? 'You do not have permission to schedule'
180
- : scheduledAt
181
- ? `Reschedule — currently publishes ${formatScheduleLabel(scheduledAt)}`
182
- : 'Schedule a future publish date'
182
+ : scheduledAt
183
+ ? `Reschedule currently publishes ${formatScheduleLabel(scheduledAt)}`
184
+ : 'Schedule a future publish date'
185
+
186
+ const submitBlocked = !canEdit || publishing || saveState === 'saving'
187
+ const requestChangesBlocked = publishing || saveState === 'saving'
188
+ let primaryAction: ReactNode
189
+ switch (workflow.primary) {
190
+ case 'submit':
191
+ primaryAction = (
192
+ <button
193
+ type="button"
194
+ onClick={onSubmitForReview}
195
+ disabled={submitBlocked}
196
+ className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
197
+ >
198
+ {publishing && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />}
199
+ {SUBMIT_FOR_REVIEW_LABEL}
200
+ </button>
201
+ )
202
+ break
203
+ case 'approve':
204
+ primaryAction = (
205
+ <button
206
+ type="button"
207
+ onClick={onApprove}
208
+ disabled={approveBlocked}
209
+ title={approveTitle}
210
+ aria-disabled={approveBlocked}
211
+ className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
212
+ >
213
+ {publishing && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />}
214
+ {APPROVE_LABEL}
215
+ </button>
216
+ )
217
+ break
218
+ case 'waiting':
219
+ primaryAction = (
220
+ <span className="text-muted-foreground text-sm" aria-live="polite">
221
+ {workflow.waitingLabel}
222
+ </span>
223
+ )
224
+ break
225
+ case null:
226
+ primaryAction = null
227
+ break
228
+ default: {
229
+ const _exhaustive: never = workflow.primary
230
+ primaryAction = _exhaustive
231
+ }
232
+ }
183
233
 
184
234
  return (
185
235
  <header className="prenta-editor-header bg-card border-border flex h-14 shrink-0 items-center justify-between gap-4 border-b px-4">
@@ -199,11 +249,16 @@ export function EditorTopBar({
199
249
  <span className="text-foreground truncate font-medium">{title || 'Untitled page'}</span>
200
250
  </nav>
201
251
  <span
202
- className={`ml-2 shrink-0 text-xs font-medium ${statusInfo.className}`}
203
- aria-label={`Status: ${statusInfo.label}`}
252
+ className={`ml-2 shrink-0 text-xs font-medium ${workflow.statusClassName}`}
253
+ aria-label={`Status: ${workflow.statusLabel}`}
204
254
  >
205
- {statusInfo.label}
255
+ {workflow.statusLabel}
206
256
  </span>
257
+ {reviewNote ? (
258
+ <span className="text-muted-foreground text-xs">
259
+ {REVIEWER_NOTE_PREFIX} {reviewNote}
260
+ </span>
261
+ ) : null}
207
262
  {scheduledAt && (
208
263
  <span
209
264
  className="text-muted-foreground ml-1 hidden shrink-0 text-xs sm:inline"
@@ -351,7 +406,7 @@ export function EditorTopBar({
351
406
  {saveState === 'saving' ? 'Saving…' : 'Save Draft'}
352
407
  </button>
353
408
 
354
- {onSchedule && (
409
+ {onSchedule && workflow.showSchedule && (
355
410
  <button
356
411
  type="button"
357
412
  onClick={onSchedule}
@@ -365,17 +420,24 @@ export function EditorTopBar({
365
420
  </button>
366
421
  )}
367
422
 
368
- <button
369
- type="button"
370
- onClick={onPublish}
371
- disabled={publishBlocked}
372
- title={publishTitle}
373
- aria-disabled={publishBlocked}
374
- className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center gap-1.5 rounded-lg px-3.5 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
375
- >
376
- {publishing && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />}
377
- Publish
378
- </button>
423
+ {primaryAction}
424
+
425
+ {workflow.showRequestChanges && (
426
+ <button
427
+ type="button"
428
+ onClick={() => setRequestChangesOpen(true)}
429
+ disabled={requestChangesBlocked}
430
+ className="border-border text-foreground hover:bg-accent rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
431
+ >
432
+ {REQUEST_CHANGES_LABEL}
433
+ </button>
434
+ )}
435
+
436
+ <RequestChangesDialog
437
+ open={requestChangesOpen}
438
+ onClose={() => setRequestChangesOpen(false)}
439
+ onConfirm={onRequestChanges}
440
+ />
379
441
  </div>
380
442
  </header>
381
443
  )
@@ -22,16 +22,17 @@ import {
22
22
  emptyPage,
23
23
  fetchPageForEditor,
24
24
  missingPageFields,
25
- publishPage,
26
25
  PAGES_COLLECTION,
27
26
  savePageDraft,
28
27
  toggleSectionVisibility,
28
+ transitionPageWorkflow,
29
29
  updateSectionContent,
30
30
  updateSectionMeta,
31
31
  validatePage,
32
32
  type EditorPage,
33
33
  type ValidationResult,
34
34
  } from '../../lib/page-editor-service.js'
35
+ import { resolveWorkflowChrome, type WorkflowStage } from '../../lib/workflow-chrome.js'
35
36
  import { SharePreviewLinkDialog } from '../../components/SharePreviewLinkDialog.js'
36
37
  import { openPublicSiteTab } from '../../lib/open-public-site-tab.js'
37
38
  import type { ViewSiteIntent } from '../../lib/view-site-chrome.js'
@@ -210,13 +211,13 @@ export function PageSectionEditor({
210
211
  /** Index into `versions`; 0 is the newest, which is the current content. */
211
212
  const [versionIndex, setVersionIndex] = useState(0)
212
213
  const [restoring, setRestoring] = useState(false)
213
- const [confirmPublish, setConfirmPublish] = useState(false)
214
214
  const [scheduleOpen, setScheduleOpen] = useState(false)
215
215
  const [shareOpen, setShareOpen] = useState(false)
216
216
  const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
217
217
  const [validation, setValidation] = useState<ValidationResult | null>(null)
218
218
 
219
- const { canEdit, canPublish, canCreateRedirect } = useMemo(() => deriveCan(session), [session])
219
+ const { canEdit, canCreateRedirect } = useMemo(() => deriveCan(session), [session])
220
+ const sessionRole = String(session?.user?.role ?? '')
220
221
  const customRenderers = useAdminSectionRenderers()
221
222
  const {
222
223
  siteUrl: publicSiteUrl,
@@ -535,43 +536,115 @@ export function PageSectionEditor({
535
536
  [directives, reloadAudit],
536
537
  )
537
538
 
538
- const handleSaveDraft = useCallback(async (): Promise<boolean> => {
539
- if (!page) return false
540
- const result = validatePage(page, false)
541
- if (result.errors.length > 0) {
542
- setValidation(result)
543
- toast.error(result.errors[0]!)
544
- setSettingsOpen(true)
545
- return false
546
- }
547
- setSaveState('saving')
548
- try {
549
- const saved = await savePageDraft(page)
550
- if (saved.id) await saveDirectives(saved.id)
551
- acceptPage(saved)
552
- setDirty(false)
553
- setSaveState('saved')
554
- setValidation(null)
555
- toast.success('Draft saved')
556
- if (!documentId && saved.id) onNavigate(`/pages/${saved.id}/edit`)
557
- return true
558
- } catch (err) {
559
- setSaveState('error')
560
- toast.error(err instanceof Error ? err.message : 'Failed to save draft')
561
- return false
562
- }
563
- }, [page, documentId, onNavigate, saveDirectives, acceptPage])
539
+ const handleSaveDraft = useCallback(
540
+ async (options?: { navigate?: boolean }): Promise<EditorPage | false> => {
541
+ if (!page) return false
542
+ const result = validatePage(page, false)
543
+ if (result.errors.length > 0) {
544
+ setValidation(result)
545
+ toast.error(result.errors[0]!)
546
+ setSettingsOpen(true)
547
+ return false
548
+ }
549
+ setSaveState('saving')
550
+ try {
551
+ const saved = await savePageDraft(page)
552
+ if (saved.id) await saveDirectives(saved.id)
553
+ acceptPage(saved)
554
+ setDirty(false)
555
+ setSaveState('saved')
556
+ setValidation(null)
557
+ toast.success('Draft saved')
558
+ if ((options?.navigate ?? true) && !documentId && saved.id) {
559
+ onNavigate(`/pages/${saved.id}/edit`)
560
+ }
561
+ return saved
562
+ } catch (err) {
563
+ setSaveState('error')
564
+ toast.error(err instanceof Error ? err.message : 'Failed to save draft')
565
+ return false
566
+ }
567
+ },
568
+ [page, documentId, onNavigate, saveDirectives, acceptPage],
569
+ )
570
+
571
+ const runWorkflow = useCallback(
572
+ async (stage: WorkflowStage, note?: string, toastOk?: string) => {
573
+ if (!page) return
574
+ let current = page
575
+ if (dirty || !current.id) {
576
+ const saved = await handleSaveDraft({ navigate: false })
577
+ if (!saved) return
578
+ current = saved
579
+ }
580
+ setPublishing(true)
581
+ try {
582
+ // The move has to be read BEFORE the transition — `acceptPage` moves
583
+ // the published path to the new one, after which there is nothing to
584
+ // detect. Same block as the old `doPublish`.
585
+ const move =
586
+ stage === 'PUBLISHED' && redirectOnPublish && canCreateRedirect
587
+ ? pendingSlugChange(publishedPath, current.path)
588
+ : null
589
+
590
+ const next = await transitionPageWorkflow(current, stage, note)
591
+
592
+ if (move) {
593
+ const { error } = await createPublishRedirect(move)
594
+ if (error) {
595
+ // Loud, and naming both paths: the page has already moved, so a
596
+ // silently-failed redirect leaves exactly the broken links the
597
+ // switch promised to prevent.
598
+ toast.error(`Published, but the redirect ${move.from} → ${move.to} failed: ${error}`)
599
+ } else {
600
+ toast.success(`Redirect created: ${move.from} → ${move.to}`)
601
+ }
602
+ }
603
+
604
+ if (stage === 'PUBLISHED' && next.id) await saveDirectives(next.id)
605
+
606
+ acceptPage(next)
607
+ if (!documentId && next.id) onNavigate(`/pages/${next.id}/edit`)
608
+ if (toastOk) toast.success(toastOk)
609
+ } catch (err) {
610
+ toast.error(err instanceof Error ? err.message : 'Transition failed')
611
+ } finally {
612
+ setPublishing(false)
613
+ }
614
+ },
615
+ [
616
+ page,
617
+ dirty,
618
+ documentId,
619
+ handleSaveDraft,
620
+ redirectOnPublish,
621
+ canCreateRedirect,
622
+ publishedPath,
623
+ acceptPage,
624
+ saveDirectives,
625
+ onNavigate,
626
+ ],
627
+ )
564
628
 
565
- const requestPublish = useCallback(() => {
629
+ const handleSubmitForReview = useCallback(
630
+ () => runWorkflow('IN_REVIEW', undefined, 'Submitted for review.'),
631
+ [runWorkflow],
632
+ )
633
+
634
+ const handleApprove = useCallback(() => {
566
635
  if (!page) return
567
636
  const result = validatePage(page, true)
568
- setValidation(result.errors.length || result.warnings.length ? result : null)
569
637
  if (result.errors.length > 0) {
570
638
  toast.error('Resolve the listed issues before publishing')
571
639
  return
572
640
  }
573
- setConfirmPublish(true)
574
- }, [page])
641
+ void runWorkflow('PUBLISHED', undefined, 'Published.')
642
+ }, [page, runWorkflow])
643
+
644
+ const handleRequestChanges = useCallback(
645
+ (note: string) => runWorkflow('DRAFT', note || undefined, 'Sent back to draft.'),
646
+ [runWorkflow],
647
+ )
575
648
 
576
649
  const requestSchedule = useCallback(async () => {
577
650
  if (!page) return
@@ -589,8 +662,15 @@ export function PageSectionEditor({
589
662
  const ok = await handleSaveDraft()
590
663
  if (!ok) return
591
664
  }
665
+ const chrome = resolveWorkflowChrome({
666
+ stage: page.workflowStage,
667
+ status: page.status,
668
+ role: sessionRole,
669
+ canEdit: effectiveCanEdit,
670
+ })
671
+ if (!chrome.showSchedule) return
592
672
  setScheduleOpen(true)
593
- }, [page, dirty, handleSaveDraft])
673
+ }, [page, dirty, handleSaveDraft, sessionRole, effectiveCanEdit])
594
674
 
595
675
  const handleScheduled = useCallback(
596
676
  (next: { status: string; scheduledAt: string | null; scheduledUnpublishAt: string | null }) => {
@@ -609,52 +689,6 @@ export function PageSectionEditor({
609
689
  [],
610
690
  )
611
691
 
612
- const doPublish = useCallback(async () => {
613
- if (!page) return
614
- setPublishing(true)
615
- try {
616
- // The move has to be read BEFORE publishing — `acceptPage` moves the
617
- // published path to the new one, after which there is nothing to detect.
618
- const move =
619
- redirectOnPublish && canCreateRedirect ? pendingSlugChange(publishedPath, page.path) : null
620
-
621
- const published = await publishPage(page)
622
- if (published.id) await saveDirectives(published.id)
623
-
624
- if (move) {
625
- const { error } = await createPublishRedirect(move)
626
- if (error) {
627
- // Loud, and naming both paths: the page has already moved, so a
628
- // silently-failed redirect leaves exactly the broken links the switch
629
- // promised to prevent.
630
- toast.error(`Published, but the redirect ${move.from} → ${move.to} failed: ${error}`)
631
- } else {
632
- toast.success(`Redirect created: ${move.from} → ${move.to}`)
633
- }
634
- }
635
-
636
- acceptPage(published)
637
- setDirty(false)
638
- setSaveState('saved')
639
- setValidation(null)
640
- toast.success('Page published')
641
- if (!documentId && published.id) onNavigate(`/pages/${published.id}/edit`)
642
- } catch (err) {
643
- toast.error(err instanceof Error ? err.message : 'Failed to publish page')
644
- } finally {
645
- setPublishing(false)
646
- }
647
- }, [
648
- page,
649
- documentId,
650
- onNavigate,
651
- saveDirectives,
652
- redirectOnPublish,
653
- canCreateRedirect,
654
- publishedPath,
655
- acceptPage,
656
- ])
657
-
658
692
  const handleDuplicate = useCallback(async () => {
659
693
  if (!page?.id) return
660
694
  const { id, error } = await duplicatePage(page.id)
@@ -754,7 +788,7 @@ export function PageSectionEditor({
754
788
  path: page.path,
755
789
  trailingSlash,
756
790
  dirty,
757
- saveDraft: handleSaveDraft,
791
+ saveDraft: async () => Boolean(await handleSaveDraft()),
758
792
  }).catch((err) => {
759
793
  toast.error(err instanceof Error ? err.message : 'Failed to open the site')
760
794
  })
@@ -833,11 +867,13 @@ export function PageSectionEditor({
833
867
  <EditorTopBar
834
868
  title={page.title}
835
869
  status={page.status}
870
+ workflowStage={page.workflowStage}
871
+ reviewNote={page.reviewNote}
872
+ role={sessionRole}
836
873
  dirty={dirty}
837
874
  saveState={saveState}
838
875
  publishing={publishing}
839
876
  canEdit={effectiveCanEdit}
840
- canPublish={canPublish && effectiveCanEdit}
841
877
  publishReady={publishReady}
842
878
  publishDisabledReason={publishDisabledReason}
843
879
  viewport={viewport}
@@ -863,7 +899,9 @@ export function PageSectionEditor({
863
899
  typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
864
900
  }
865
901
  onSaveDraft={handleSaveDraft}
866
- onPublish={requestPublish}
902
+ onSubmitForReview={handleSubmitForReview}
903
+ onApprove={handleApprove}
904
+ onRequestChanges={handleRequestChanges}
867
905
  onSchedule={page.id ? requestSchedule : undefined}
868
906
  scheduleReady={Boolean(page.id)}
869
907
  scheduledAt={page.scheduledAt}
@@ -1037,15 +1075,6 @@ export function PageSectionEditor({
1037
1075
  onSave={handlePageSettings}
1038
1076
  />
1039
1077
 
1040
- <ConfirmDialog
1041
- open={confirmPublish}
1042
- onClose={() => setConfirmPublish(false)}
1043
- onConfirm={doPublish}
1044
- title="Publish this page?"
1045
- description="The current content will go live and replace the published version."
1046
- confirmLabel="Publish"
1047
- />
1048
-
1049
1078
  {page.id && (
1050
1079
  <SchedulePublishDialog
1051
1080
  collectionSlug={PAGES_COLLECTION}