@prenta/admin 1.4.0 → 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 (119) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +5 -0
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/request-changes-dialog.render.test.d.ts +2 -0
  6. package/dist/__tests__/components/request-changes-dialog.render.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/request-changes-dialog.render.test.js +12 -0
  8. package/dist/__tests__/components/request-changes-dialog.render.test.js.map +1 -0
  9. package/dist/__tests__/layout/sidebar-posts.test.js +1 -0
  10. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
  11. package/dist/__tests__/lib/page-editor-service-api.test.js +28 -15
  12. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
  13. package/dist/__tests__/lib/post-editor-service-api.test.d.ts +2 -0
  14. package/dist/__tests__/lib/post-editor-service-api.test.d.ts.map +1 -0
  15. package/dist/__tests__/lib/post-editor-service-api.test.js +67 -0
  16. package/dist/__tests__/lib/post-editor-service-api.test.js.map +1 -0
  17. package/dist/__tests__/lib/post-editor-service.test.js +2 -0
  18. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  19. package/dist/__tests__/lib/workflow-chrome.test.d.ts +2 -0
  20. package/dist/__tests__/lib/workflow-chrome.test.d.ts.map +1 -0
  21. package/dist/__tests__/lib/workflow-chrome.test.js +108 -0
  22. package/dist/__tests__/lib/workflow-chrome.test.js.map +1 -0
  23. package/dist/__tests__/router/admin-routes.test.js +1 -0
  24. package/dist/__tests__/router/admin-routes.test.js.map +1 -1
  25. package/dist/__tests__/views/editor-top-bar.render.test.js +40 -33
  26. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  27. package/dist/__tests__/views/page-section-editor.test.js +8 -0
  28. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  29. package/dist/__tests__/views/page-tab.render.test.js +2 -0
  30. package/dist/__tests__/views/page-tab.render.test.js.map +1 -1
  31. package/dist/__tests__/views/post-fields-modal.render.test.js +2 -0
  32. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  33. package/dist/__tests__/views/post-section-editor.test.js +2 -0
  34. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  35. package/dist/__tests__/views/post-tab.render.test.js +2 -0
  36. package/dist/__tests__/views/post-tab.render.test.js.map +1 -1
  37. package/dist/__tests__/views/trash.render.test.d.ts +2 -0
  38. package/dist/__tests__/views/trash.render.test.d.ts.map +1 -0
  39. package/dist/__tests__/views/trash.render.test.js +55 -0
  40. package/dist/__tests__/views/trash.render.test.js.map +1 -0
  41. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  42. package/dist/components/Breadcrumbs.js +1 -0
  43. package/dist/components/Breadcrumbs.js.map +1 -1
  44. package/dist/components/RequestChangesDialog.d.ts +6 -0
  45. package/dist/components/RequestChangesDialog.d.ts.map +1 -0
  46. package/dist/components/RequestChangesDialog.js +15 -0
  47. package/dist/components/RequestChangesDialog.js.map +1 -0
  48. package/dist/layout/Sidebar.d.ts.map +1 -1
  49. package/dist/layout/Sidebar.js +3 -2
  50. package/dist/layout/Sidebar.js.map +1 -1
  51. package/dist/lib/page-editor-service.d.ts +6 -2
  52. package/dist/lib/page-editor-service.d.ts.map +1 -1
  53. package/dist/lib/page-editor-service.js +16 -12
  54. package/dist/lib/page-editor-service.js.map +1 -1
  55. package/dist/lib/post-editor-service.d.ts +6 -1
  56. package/dist/lib/post-editor-service.d.ts.map +1 -1
  57. package/dist/lib/post-editor-service.js +16 -11
  58. package/dist/lib/post-editor-service.js.map +1 -1
  59. package/dist/lib/workflow-chrome.d.ts +22 -0
  60. package/dist/lib/workflow-chrome.d.ts.map +1 -0
  61. package/dist/lib/workflow-chrome.js +80 -0
  62. package/dist/lib/workflow-chrome.js.map +1 -0
  63. package/dist/prenta-admin.css +1 -1
  64. package/dist/router/admin-routes.d.ts.map +1 -1
  65. package/dist/router/admin-routes.js +2 -0
  66. package/dist/router/admin-routes.js.map +1 -1
  67. package/dist/views/ApiKeys.js +1 -5
  68. package/dist/views/ApiKeys.js.map +1 -1
  69. package/dist/views/SEO.d.ts.map +1 -1
  70. package/dist/views/SEO.js +1 -1
  71. package/dist/views/SEO.js.map +1 -1
  72. package/dist/views/Trash.d.ts +18 -0
  73. package/dist/views/Trash.d.ts.map +1 -0
  74. package/dist/views/Trash.js +104 -0
  75. package/dist/views/Trash.js.map +1 -0
  76. package/dist/views/page-editor/EditorTopBar.d.ts +10 -5
  77. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  78. package/dist/views/page-editor/EditorTopBar.js +38 -28
  79. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  80. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  81. package/dist/views/page-editor/PageSectionEditor.js +83 -62
  82. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  83. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  84. package/dist/views/post-editor/PostSectionEditor.js +78 -62
  85. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  86. package/dist/views/seo/OverviewTab.d.ts +1 -2
  87. package/dist/views/seo/OverviewTab.d.ts.map +1 -1
  88. package/dist/views/seo/OverviewTab.js +3 -3
  89. package/dist/views/seo/OverviewTab.js.map +1 -1
  90. package/package.json +4 -4
  91. package/src/AdminRoot.tsx +6 -0
  92. package/src/__tests__/components/request-changes-dialog.render.test.tsx +11 -0
  93. package/src/__tests__/layout/sidebar-posts.test.ts +1 -0
  94. package/src/__tests__/lib/page-editor-service-api.test.ts +37 -18
  95. package/src/__tests__/lib/post-editor-service-api.test.ts +73 -0
  96. package/src/__tests__/lib/post-editor-service.test.ts +2 -0
  97. package/src/__tests__/lib/workflow-chrome.test.ts +125 -0
  98. package/src/__tests__/router/admin-routes.test.ts +1 -0
  99. package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -45
  100. package/src/__tests__/views/page-section-editor.test.tsx +18 -0
  101. package/src/__tests__/views/page-tab.render.test.tsx +2 -0
  102. package/src/__tests__/views/post-fields-modal.render.test.tsx +2 -0
  103. package/src/__tests__/views/post-section-editor.test.tsx +2 -0
  104. package/src/__tests__/views/post-tab.render.test.tsx +2 -0
  105. package/src/__tests__/views/trash.render.test.tsx +61 -0
  106. package/src/components/Breadcrumbs.tsx +1 -0
  107. package/src/components/RequestChangesDialog.tsx +56 -0
  108. package/src/layout/Sidebar.tsx +3 -0
  109. package/src/lib/page-editor-service.ts +26 -11
  110. package/src/lib/post-editor-service.ts +26 -10
  111. package/src/lib/workflow-chrome.ts +105 -0
  112. package/src/router/admin-routes.ts +2 -0
  113. package/src/views/ApiKeys.tsx +0 -13
  114. package/src/views/SEO.tsx +0 -5
  115. package/src/views/Trash.tsx +207 -0
  116. package/src/views/page-editor/EditorTopBar.tsx +110 -48
  117. package/src/views/page-editor/PageSectionEditor.tsx +121 -92
  118. package/src/views/post-editor/PostSectionEditor.tsx +111 -88
  119. package/src/views/seo/OverviewTab.tsx +1 -11
@@ -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}
@@ -25,9 +25,9 @@ import {
25
25
  fetchPostForEditor,
26
26
  fetchTemplateForEditor,
27
27
  missingPostFields,
28
- publishPost,
29
28
  savePostDraft,
30
29
  toggleSectionVisibility,
30
+ transitionPostWorkflow,
31
31
  updateSectionContent,
32
32
  updateSectionMeta,
33
33
  validatePost,
@@ -35,6 +35,7 @@ import {
35
35
  type PostHeaderConfig,
36
36
  type ValidationResult,
37
37
  } from '../../lib/post-editor-service.js'
38
+ import { resolveWorkflowChrome, type WorkflowStage } from '../../lib/workflow-chrome.js'
38
39
  import { SharePreviewLinkDialog } from '../../components/SharePreviewLinkDialog.js'
39
40
  import { defaultPostHeader } from '@prenta/core/page-sections'
40
41
  import { openPublicSiteTab } from '../../lib/open-public-site-tab.js'
@@ -160,7 +161,6 @@ export function PostSectionEditor({
160
161
  * audit so the header chip and tab dot always have a number.
161
162
  */
162
163
  const [liveAudit, setLiveAudit] = useState<PageAuditResult | null>(null)
163
- const [confirmPublish, setConfirmPublish] = useState(false)
164
164
  const [scheduleOpen, setScheduleOpen] = useState(false)
165
165
  const [shareOpen, setShareOpen] = useState(false)
166
166
  const [leaveTarget, setLeaveTarget] = useState<string | null>(null)
@@ -180,7 +180,8 @@ export function PostSectionEditor({
180
180
  setBaseline(snapshotBaseline(p.sections, p.status))
181
181
  }, [])
182
182
 
183
- const { canEdit, canPublish } = useMemo(() => deriveCan(session), [session])
183
+ const { canEdit } = useMemo(() => deriveCan(session), [session])
184
+ const sessionRole = String(session?.user?.role ?? '')
184
185
  const customRenderers = useAdminSectionRenderers()
185
186
  const {
186
187
  siteUrl: publicSiteUrl,
@@ -391,61 +392,36 @@ export function PostSectionEditor({
391
392
  }, [])
392
393
 
393
394
  // ─── Save / publish ──────────────────────────────────────────────────
394
- const handleSaveDraft = useCallback(async (): Promise<boolean> => {
395
- if (!post) return false
396
- const result = validatePost(post, false)
397
- if (result.errors.length > 0) {
398
- setValidation(result)
399
- toast.error(result.errors[0]!)
400
- setFieldsOpen(true)
401
- return false
402
- }
403
- setSaveState('saving')
404
- try {
405
- const saved = await savePostDraft(post)
406
- acceptPost(saved)
407
- setDirty(false)
408
- setSaveState('saved')
409
- setValidation(null)
410
- toast.success('Draft saved')
411
- if (!documentId && saved.id) onNavigate(`/posts/${postType}/${saved.id}/edit`)
412
- return true
413
- } catch (err) {
414
- setSaveState('error')
415
- toast.error(err instanceof Error ? err.message : 'Failed to save draft')
416
- return false
417
- }
418
- }, [post, documentId, postType, onNavigate])
419
-
420
- const requestPublish = useCallback(() => {
421
- if (!post) return
422
- const result = validatePost(post, true)
423
- setValidation(result.errors.length || result.warnings.length ? result : null)
424
- if (result.errors.length > 0) {
425
- toast.error('Resolve the listed issues before publishing')
426
- return
427
- }
428
- setConfirmPublish(true)
429
- }, [post])
430
-
431
- const requestSchedule = useCallback(async () => {
432
- if (!post) return
433
- if (!post.id) {
434
- toast.error('Save the post before scheduling')
435
- return
436
- }
437
- const result = validatePost(post, true)
438
- setValidation(result.errors.length || result.warnings.length ? result : null)
439
- if (result.errors.length > 0) {
440
- toast.error('Resolve the listed issues before scheduling')
441
- return
442
- }
443
- if (dirty) {
444
- const ok = await handleSaveDraft()
445
- if (!ok) return
446
- }
447
- setScheduleOpen(true)
448
- }, [post, dirty, handleSaveDraft])
395
+ const handleSaveDraft = useCallback(
396
+ async (options?: { navigate?: boolean }): Promise<EditorPost | false> => {
397
+ if (!post) return false
398
+ const result = validatePost(post, false)
399
+ if (result.errors.length > 0) {
400
+ setValidation(result)
401
+ toast.error(result.errors[0]!)
402
+ setFieldsOpen(true)
403
+ return false
404
+ }
405
+ setSaveState('saving')
406
+ try {
407
+ const saved = await savePostDraft(post)
408
+ acceptPost(saved)
409
+ setDirty(false)
410
+ setSaveState('saved')
411
+ setValidation(null)
412
+ toast.success('Draft saved')
413
+ if ((options?.navigate ?? true) && !documentId && saved.id) {
414
+ onNavigate(`/posts/${postType}/${saved.id}/edit`)
415
+ }
416
+ return saved
417
+ } catch (err) {
418
+ setSaveState('error')
419
+ toast.error(err instanceof Error ? err.message : 'Failed to save draft')
420
+ return false
421
+ }
422
+ },
423
+ [post, documentId, postType, onNavigate, acceptPost],
424
+ )
449
425
 
450
426
  const handleScheduled = useCallback(
451
427
  (next: { status: string; scheduledAt: string | null; scheduledUnpublishAt: string | null }) => {
@@ -465,24 +441,6 @@ export function PostSectionEditor({
465
441
  [],
466
442
  )
467
443
 
468
- const doPublish = useCallback(async () => {
469
- if (!post) return
470
- setPublishing(true)
471
- try {
472
- const published = await publishPost(post)
473
- acceptPost(published)
474
- setDirty(false)
475
- setSaveState('saved')
476
- setValidation(null)
477
- toast.success('Post published')
478
- if (!documentId && published.id) onNavigate(`/posts/${postType}/${published.id}/edit`)
479
- } catch (err) {
480
- toast.error(err instanceof Error ? err.message : 'Failed to publish post')
481
- } finally {
482
- setPublishing(false)
483
- }
484
- }, [post, documentId, postType, onNavigate])
485
-
486
444
  // ─── Version history ─────────────────────────────────────────────────
487
445
  // History is a MODE, not a dialog: it overlays the rail and drives the
488
446
  // canvas, so the version being reviewed is editor state rather than
@@ -555,6 +513,76 @@ export function PostSectionEditor({
555
513
  const effectiveCanEdit = canEdit && !reviewing
556
514
  const canRestoreVersion = canEdit
557
515
 
516
+ const runWorkflow = useCallback(
517
+ async (stage: WorkflowStage, note?: string, toastOk?: string) => {
518
+ if (!post) return
519
+ let current = post
520
+ if (dirty || !current.id) {
521
+ const saved = await handleSaveDraft({ navigate: false })
522
+ if (!saved) return
523
+ current = saved
524
+ }
525
+ setPublishing(true)
526
+ try {
527
+ const next = await transitionPostWorkflow(current, stage, note)
528
+ acceptPost(next)
529
+ if (!documentId && next.id) onNavigate(`/posts/${postType}/${next.id}/edit`)
530
+ if (toastOk) toast.success(toastOk)
531
+ } catch (err) {
532
+ toast.error(err instanceof Error ? err.message : 'Transition failed')
533
+ } finally {
534
+ setPublishing(false)
535
+ }
536
+ },
537
+ [post, dirty, documentId, postType, handleSaveDraft, acceptPost, onNavigate],
538
+ )
539
+
540
+ const handleSubmitForReview = useCallback(
541
+ () => runWorkflow('IN_REVIEW', undefined, 'Submitted for review.'),
542
+ [runWorkflow],
543
+ )
544
+
545
+ const handleApprove = useCallback(() => {
546
+ if (!post) return
547
+ const result = validatePost(post, true)
548
+ if (result.errors.length > 0) {
549
+ toast.error('Resolve the listed issues before publishing')
550
+ return
551
+ }
552
+ void runWorkflow('PUBLISHED', undefined, 'Published.')
553
+ }, [post, runWorkflow])
554
+
555
+ const handleRequestChanges = useCallback(
556
+ (note: string) => runWorkflow('DRAFT', note || undefined, 'Sent back to draft.'),
557
+ [runWorkflow],
558
+ )
559
+
560
+ const requestSchedule = useCallback(async () => {
561
+ if (!post) return
562
+ if (!post.id) {
563
+ toast.error('Save the post before scheduling')
564
+ return
565
+ }
566
+ const result = validatePost(post, true)
567
+ setValidation(result.errors.length || result.warnings.length ? result : null)
568
+ if (result.errors.length > 0) {
569
+ toast.error('Resolve the listed issues before scheduling')
570
+ return
571
+ }
572
+ if (dirty) {
573
+ const ok = await handleSaveDraft()
574
+ if (!ok) return
575
+ }
576
+ const chrome = resolveWorkflowChrome({
577
+ stage: post.workflowStage,
578
+ status: post.status,
579
+ role: sessionRole,
580
+ canEdit: effectiveCanEdit,
581
+ })
582
+ if (!chrome.showSchedule) return
583
+ setScheduleOpen(true)
584
+ }, [post, dirty, handleSaveDraft, sessionRole, effectiveCanEdit])
585
+
558
586
  const handleSeoSaved = useCallback(() => {
559
587
  if (!post?.id) return
560
588
  fetchPostForEditor(postType, post.id)
@@ -616,7 +644,7 @@ export function PostSectionEditor({
616
644
  data: { path: (post as { path?: string }).path },
617
645
  trailingSlash,
618
646
  dirty,
619
- saveDraft: handleSaveDraft,
647
+ saveDraft: async () => Boolean(await handleSaveDraft()),
620
648
  }).catch((err) => {
621
649
  toast.error(err instanceof Error ? err.message : 'Failed to open the site')
622
650
  })
@@ -688,11 +716,13 @@ export function PostSectionEditor({
688
716
  <EditorTopBar
689
717
  title={post.title || `New ${typeLabel}`}
690
718
  status={post.status}
719
+ workflowStage={post.workflowStage}
720
+ reviewNote={post.reviewNote}
721
+ role={sessionRole}
691
722
  dirty={dirty}
692
723
  saveState={saveState}
693
724
  publishing={publishing}
694
725
  canEdit={effectiveCanEdit}
695
- canPublish={canPublish && effectiveCanEdit}
696
726
  publishReady={publishReady}
697
727
  publishDisabledReason={publishDisabledReason}
698
728
  viewport={viewport}
@@ -716,7 +746,9 @@ export function PostSectionEditor({
716
746
  typeof audit?.seo.score === 'number' ? scoreBand(audit.seo.score) : undefined
717
747
  }
718
748
  onSaveDraft={handleSaveDraft}
719
- onPublish={requestPublish}
749
+ onSubmitForReview={handleSubmitForReview}
750
+ onApprove={handleApprove}
751
+ onRequestChanges={handleRequestChanges}
720
752
  onSchedule={post.id ? requestSchedule : undefined}
721
753
  scheduleReady={Boolean(post.id)}
722
754
  scheduledAt={post.scheduledAt}
@@ -889,15 +921,6 @@ export function PostSectionEditor({
889
921
  onSave={handleFields}
890
922
  />
891
923
 
892
- <ConfirmDialog
893
- open={confirmPublish}
894
- onClose={() => setConfirmPublish(false)}
895
- onConfirm={doPublish}
896
- title="Publish this post?"
897
- description="The current content will go live and replace the published version."
898
- confirmLabel="Publish"
899
- />
900
-
901
924
  {post.id && (
902
925
  <SchedulePublishDialog
903
926
  collectionSlug={postType}
@@ -19,7 +19,6 @@ import {
19
19
  SeoEmptyState,
20
20
  SeoErrorState,
21
21
  gradeMeta,
22
- btnSecondary,
23
22
  } from '../../components/seo/primitives.js'
24
23
  import type { SeoResource } from './useSeoResource.js'
25
24
 
@@ -36,11 +35,9 @@ function impactTone(impact: QuickWin['impact']): 'good' | 'fair' | 'poor' {
36
35
  export function OverviewTab({
37
36
  resource,
38
37
  onOpenIssue,
39
- onConnectSearchConsole,
40
38
  }: {
41
39
  resource: SeoResource<SeoOverview>
42
40
  onOpenIssue?: (issueId: string) => void
43
- onConnectSearchConsole?: () => void
44
41
  }) {
45
42
  const { data, loading, error, refetch } = resource
46
43
 
@@ -203,14 +200,7 @@ export function OverviewTab({
203
200
  <SeoEmptyState
204
201
  icon={<BarChart3 size={24} />}
205
202
  title="Search Console not connected"
206
- description="Connect Google Search Console to see real impressions, clicks, CTR, and average position."
207
- action={
208
- onConnectSearchConsole ? (
209
- <button className={btnSecondary} onClick={onConnectSearchConsole}>
210
- Connect Search Console
211
- </button>
212
- ) : undefined
213
- }
203
+ description="Add a Google Search Console verification token in Settings SEO. Performance charts will appear here when the Search Analytics API is wired."
214
204
  />
215
205
  ) : searchPerformance.length === 0 ? (
216
206
  <SeoEmptyState title="No search performance data" />