@prenta/admin 1.16.0 → 1.17.1

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 (97) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts +2 -0
  3. package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/faq-proposal-preview.render.test.js +97 -0
  5. package/dist/__tests__/components/faq-proposal-preview.render.test.js.map +1 -0
  6. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +37 -11
  7. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -1
  8. package/dist/__tests__/lib/seo-service-errors.test.js +5 -6
  9. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  10. package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
  11. package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
  12. package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
  13. package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
  14. package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
  15. package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
  16. package/dist/components/seo/FaqProposalPreview.js +62 -0
  17. package/dist/components/seo/FaqProposalPreview.js.map +1 -0
  18. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  19. package/dist/components/seo/SeoIssueFixPanel.js +7 -2
  20. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  21. package/dist/lib/seo-service.d.ts +61 -7
  22. package/dist/lib/seo-service.d.ts.map +1 -1
  23. package/dist/lib/seo-service.js +9 -13
  24. package/dist/lib/seo-service.js.map +1 -1
  25. package/dist/prenta-admin.css +1 -1
  26. package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
  27. package/dist/views/seo/ProposalsTab.js +160 -7
  28. package/dist/views/seo/ProposalsTab.js.map +1 -1
  29. package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
  30. package/dist/views/settings/SeoAutopilotCard.js +6 -0
  31. package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
  32. package/package.json +5 -5
  33. package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
  34. package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
  35. package/src/__tests__/lib/seo-service-errors.test.ts +5 -9
  36. package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
  37. package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
  38. package/src/components/seo/FaqProposalPreview.tsx +134 -0
  39. package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
  40. package/src/lib/seo-service.ts +57 -15
  41. package/src/views/seo/ProposalsTab.tsx +279 -13
  42. package/src/views/settings/SeoAutopilotCard.tsx +6 -0
  43. package/dist/components/ContentOverviewChart.d.ts +0 -8
  44. package/dist/components/ContentOverviewChart.d.ts.map +0 -1
  45. package/dist/components/ContentOverviewChart.js +0 -20
  46. package/dist/components/ContentOverviewChart.js.map +0 -1
  47. package/dist/components/SEOPerformance.d.ts +0 -5
  48. package/dist/components/SEOPerformance.d.ts.map +0 -1
  49. package/dist/components/SEOPerformance.js +0 -29
  50. package/dist/components/SEOPerformance.js.map +0 -1
  51. package/dist/components/ui/Avatar.d.ts +0 -9
  52. package/dist/components/ui/Avatar.d.ts.map +0 -1
  53. package/dist/components/ui/Avatar.js +0 -21
  54. package/dist/components/ui/Avatar.js.map +0 -1
  55. package/dist/components/ui/CommandPalette.d.ts +0 -14
  56. package/dist/components/ui/CommandPalette.d.ts.map +0 -1
  57. package/dist/components/ui/CommandPalette.js +0 -58
  58. package/dist/components/ui/CommandPalette.js.map +0 -1
  59. package/dist/components/ui/DataTable.d.ts +0 -30
  60. package/dist/components/ui/DataTable.d.ts.map +0 -1
  61. package/dist/components/ui/DataTable.js +0 -44
  62. package/dist/components/ui/DataTable.js.map +0 -1
  63. package/dist/components/ui/Pagination.d.ts +0 -9
  64. package/dist/components/ui/Pagination.d.ts.map +0 -1
  65. package/dist/components/ui/Pagination.js +0 -26
  66. package/dist/components/ui/Pagination.js.map +0 -1
  67. package/dist/components/ui/Select.d.ts +0 -16
  68. package/dist/components/ui/Select.d.ts.map +0 -1
  69. package/dist/components/ui/Select.js +0 -24
  70. package/dist/components/ui/Select.js.map +0 -1
  71. package/dist/components/ui/Toast.d.ts +0 -18
  72. package/dist/components/ui/Toast.d.ts.map +0 -1
  73. package/dist/components/ui/Toast.js +0 -29
  74. package/dist/components/ui/Toast.js.map +0 -1
  75. package/dist/components/ui/index.d.ts +0 -46
  76. package/dist/components/ui/index.d.ts.map +0 -1
  77. package/dist/components/ui/index.js +0 -24
  78. package/dist/components/ui/index.js.map +0 -1
  79. package/dist/hooks/useContentLock.d.ts +0 -12
  80. package/dist/hooks/useContentLock.d.ts.map +0 -1
  81. package/dist/hooks/useContentLock.js +0 -38
  82. package/dist/hooks/useContentLock.js.map +0 -1
  83. package/dist/hooks/useDebounce.d.ts +0 -2
  84. package/dist/hooks/useDebounce.d.ts.map +0 -1
  85. package/dist/hooks/useDebounce.js +0 -11
  86. package/dist/hooks/useDebounce.js.map +0 -1
  87. package/src/components/ContentOverviewChart.tsx +0 -75
  88. package/src/components/SEOPerformance.tsx +0 -163
  89. package/src/components/ui/Avatar.tsx +0 -42
  90. package/src/components/ui/CommandPalette.tsx +0 -133
  91. package/src/components/ui/DataTable.tsx +0 -204
  92. package/src/components/ui/Pagination.tsx +0 -94
  93. package/src/components/ui/Select.tsx +0 -53
  94. package/src/components/ui/Toast.tsx +0 -72
  95. package/src/components/ui/index.ts +0 -61
  96. package/src/hooks/useContentLock.ts +0 -55
  97. package/src/hooks/useDebounce.ts +0 -14
@@ -27,10 +27,51 @@ function proposal(over: Partial<SeoProposal> = {}): SeoProposal {
27
27
  severity: 'warning',
28
28
  issueId: 'i1',
29
29
  suggestionId: null,
30
+ fixStrategy: null,
31
+ section: null,
32
+ grounding: null,
33
+ signals: null,
30
34
  ...over,
31
35
  }
32
36
  }
33
37
 
38
+ /** An `add-section` FAQ proposal with three grounded questions and both signal kinds. */
39
+ function faqProposal(over: Partial<SeoProposal> = {}): SeoProposal {
40
+ return proposal({
41
+ id: 'issue:faq1',
42
+ issueId: 'faq1',
43
+ fingerprint: 'fp-faq',
44
+ title: 'Page lacks an FAQ section',
45
+ issueType: 'missing-faq',
46
+ severity: 'info',
47
+ entity: { type: 'page', id: 'd9', title: 'Solar installs', url: '/solar' },
48
+ changes: [{ field: 'sections', label: 'Sections', before: null, after: 'FAQ (3 questions)' }],
49
+ justification: 'Answer engines cite this page; an FAQ makes the answers extractable.',
50
+ fixStrategy: 'add-section',
51
+ section: {
52
+ sectionType: 'faq',
53
+ content: {
54
+ heading: 'Questions',
55
+ items: [
56
+ { question: 'How long does it take?', answer: 'Three to five days.' },
57
+ { question: 'Can I finance?', answer: 'Yes, zero down.' },
58
+ { question: 'Warranty?', answer: '25 years.' },
59
+ ],
60
+ },
61
+ },
62
+ grounding: [
63
+ { sourceQuote: 'take three to five days' },
64
+ { sourceQuote: 'zero down' },
65
+ { sourceQuote: '' },
66
+ ],
67
+ signals: {
68
+ geo: { referralVisits: 14, retrievalHits: 0, engines: ['chatgpt'] },
69
+ queries: [{ query: 'how long does it take', impressions: 120, clicks: 3 }],
70
+ },
71
+ ...over,
72
+ })
73
+ }
74
+
34
75
  function payload(over: Partial<SeoProposalsPayload> = {}): SeoProposalsPayload {
35
76
  return {
36
77
  proposals: [
@@ -54,6 +95,7 @@ function payload(over: Partial<SeoProposalsPayload> = {}): SeoProposalsPayload {
54
95
  suggestionId: 's1',
55
96
  severity: null,
56
97
  changes: [{ field: 'redirect', label: 'Redirect', before: '/old', after: '/new' }],
98
+ fixStrategy: 'redirect',
57
99
  }),
58
100
  ],
59
101
  counts: { issueFixes: 2, redirects: 1, stale: 0, eligibleForGeneration: 5 },
@@ -86,11 +128,23 @@ const bulkApplySeoProposals = vi.fn(
86
128
  )
87
129
  const dismissSeoIssueFix = vi.fn(async (_id: string): Promise<{ error?: string }> => ({}))
88
130
  const dismissRedirectSuggestion = vi.fn(async (_id: string): Promise<{ error?: string }> => ({}))
131
+ const applySeoIssueFix = vi.fn(
132
+ async (
133
+ _issueId: string,
134
+ _fingerprint: string,
135
+ _opts?: { includeItems?: number[]; headingOverride?: string },
136
+ ): Promise<{ issue?: unknown; error?: string }> => ({}),
137
+ )
89
138
 
90
139
  vi.mock('../../lib/seo-service.js', () => ({
91
140
  fetchSeoProposals: () => fetchSeoProposals(),
92
141
  generateSeoProposals: () => generateSeoProposals(),
93
142
  bulkApplySeoProposals: (items: unknown) => bulkApplySeoProposals(items),
143
+ applySeoIssueFix: (
144
+ issueId: string,
145
+ fingerprint: string,
146
+ opts?: { includeItems?: number[]; headingOverride?: string },
147
+ ) => applySeoIssueFix(issueId, fingerprint, opts),
94
148
  dismissSeoIssueFix: (id: string) => dismissSeoIssueFix(id),
95
149
  dismissRedirectSuggestion: (id: string) => dismissRedirectSuggestion(id),
96
150
  fetchAutopilotApplied: (params: unknown) => fetchAutopilotApplied(params),
@@ -125,6 +179,8 @@ beforeEach(() => {
125
179
  bulkApplySeoProposals.mockClear()
126
180
  dismissSeoIssueFix.mockClear()
127
181
  dismissRedirectSuggestion.mockClear()
182
+ applySeoIssueFix.mockClear()
183
+ applySeoIssueFix.mockImplementation(async () => ({}))
128
184
  fetchAutopilotApplied.mockClear()
129
185
  revertAutopilotChange.mockClear()
130
186
  vi.mocked(toast.success).mockClear()
@@ -522,6 +578,308 @@ describe('ProposalsTab', () => {
522
578
  expect(fetchAutopilotApplied).toHaveBeenCalledTimes(2)
523
579
  })
524
580
 
581
+ describe('FAQ (add-section) proposals', () => {
582
+ function faqPayload(row: SeoProposal = faqProposal()): SeoProposalsPayload {
583
+ return payload({
584
+ proposals: [row],
585
+ counts: { issueFixes: 1, redirects: 0, stale: 0, eligibleForGeneration: 0 },
586
+ })
587
+ }
588
+
589
+ it('shows the FAQ badge and a question-count summary with the strongest signal', async () => {
590
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
591
+ render(<ProposalsTab planInfo={null} />)
592
+ await screen.findByText('FAQ section')
593
+ expect(screen.getByText(/^3 questions · Cited by chatgpt 14× in 28 days/)).toBeTruthy()
594
+ })
595
+
596
+ it('approves a FAQ proposal through applySeoIssueFix with includeItems and headingOverride', async () => {
597
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
598
+ render(<ProposalsTab planInfo={null} onNavigate={vi.fn()} />)
599
+ await screen.findByText('FAQ section')
600
+ fireEvent.click(
601
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
602
+ )
603
+ // The third grounding row is malformed (`''`): it must render as "no quote".
604
+ expect(screen.getByText(/take three to five days/)).toBeTruthy()
605
+ expect(screen.queryByText(/“”/)).toBeNull()
606
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
607
+ fireEvent.change(screen.getByLabelText('Section heading'), {
608
+ target: { value: 'Top questions' },
609
+ })
610
+ fireEvent.click(screen.getByRole('button', { name: /^Approve$/ }))
611
+ await waitFor(() =>
612
+ expect(applySeoIssueFix).toHaveBeenCalledWith('faq1', 'fp-faq', {
613
+ includeItems: [0, 1],
614
+ headingOverride: 'Top questions',
615
+ }),
616
+ )
617
+ expect(bulkApplySeoProposals).not.toHaveBeenCalled()
618
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('FAQ section added.'))
619
+ await waitFor(() => expect(fetchSeoProposals).toHaveBeenCalledTimes(2))
620
+ })
621
+
622
+ it('sends neither includeItems nor headingOverride when nothing was changed', async () => {
623
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
624
+ render(<ProposalsTab planInfo={null} />)
625
+ await screen.findByText('FAQ section')
626
+ fireEvent.click(screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' }))
627
+ await waitFor(() => expect(applySeoIssueFix).toHaveBeenCalledTimes(1))
628
+ expect(applySeoIssueFix).toHaveBeenCalledWith('faq1', 'fp-faq', {})
629
+ expect(bulkApplySeoProposals).not.toHaveBeenCalled()
630
+ })
631
+
632
+ it('does not send a heading override that only differs by whitespace', async () => {
633
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
634
+ render(<ProposalsTab planInfo={null} />)
635
+ await screen.findByText('FAQ section')
636
+ fireEvent.click(
637
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
638
+ )
639
+ fireEvent.change(screen.getByLabelText('Section heading'), {
640
+ target: { value: ' Questions ' },
641
+ })
642
+ fireEvent.click(screen.getByRole('button', { name: /^Approve$/ }))
643
+ await waitFor(() => expect(applySeoIssueFix).toHaveBeenCalledWith('faq1', 'fp-faq', {}))
644
+ })
645
+
646
+ it('falls back to the drafted heading when the heading field is cleared', async () => {
647
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
648
+ render(<ProposalsTab planInfo={null} />)
649
+ await screen.findByText('FAQ section')
650
+ fireEvent.click(
651
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
652
+ )
653
+ fireEvent.change(screen.getByLabelText('Section heading'), { target: { value: '' } })
654
+ expect(screen.getByText('3 of 3 questions will be added.')).toBeTruthy()
655
+ const approve = screen.getByRole('button', { name: /^Approve$/ })
656
+ expect(approve).toHaveProperty('disabled', false)
657
+ fireEvent.click(approve)
658
+ await waitFor(() => expect(applySeoIssueFix).toHaveBeenCalledWith('faq1', 'fp-faq', {}))
659
+ })
660
+
661
+ it('disables approve with a hint once fewer than two questions are selected', async () => {
662
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
663
+ render(<ProposalsTab planInfo={null} />)
664
+ await screen.findByText('FAQ section')
665
+ fireEvent.click(
666
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
667
+ )
668
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
669
+ fireEvent.click(screen.getByRole('checkbox', { name: /Can I finance\?/ }))
670
+ // The visible hint (the sr-only copy on the row button is asserted separately).
671
+ expect(
672
+ screen.getByText(/Select at least 2 questions to approve \(1 selected\)/, {
673
+ selector: '[role="status"]',
674
+ }),
675
+ ).toBeTruthy()
676
+ const approve = screen.getByRole('button', { name: /^Approve$/ })
677
+ expect(approve).toHaveProperty('disabled', true)
678
+ expect(
679
+ screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' }),
680
+ ).toHaveProperty('disabled', true)
681
+ fireEvent.click(approve)
682
+ expect(applySeoIssueFix).not.toHaveBeenCalled()
683
+ // Re-checking one restores the button.
684
+ fireEvent.click(screen.getByRole('checkbox', { name: /Can I finance\?/ }))
685
+ expect(screen.getByRole('button', { name: /^Approve$/ })).toHaveProperty('disabled', false)
686
+ })
687
+
688
+ it('exposes the blocker on the collapsed row approve button', async () => {
689
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
690
+ render(<ProposalsTab planInfo={null} />)
691
+ await screen.findByText('FAQ section')
692
+ fireEvent.click(
693
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
694
+ )
695
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
696
+ fireEvent.click(screen.getByRole('checkbox', { name: /Can I finance\?/ }))
697
+ fireEvent.click(
698
+ screen.getByRole('button', { name: 'Hide changes for Page lacks an FAQ section' }),
699
+ )
700
+ const approve = screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' })
701
+ expect(approve).toHaveProperty('disabled', true)
702
+ expect(approve.getAttribute('title')).toMatch(/Select at least 2 questions/)
703
+ const describedBy = approve.getAttribute('aria-describedby') ?? ''
704
+ expect(describedBy).not.toBe('')
705
+ expect(document.getElementById(describedBy)?.textContent).toMatch(
706
+ /Select at least 2 questions to approve \(1 selected\)/,
707
+ )
708
+ })
709
+
710
+ it('keeps per-row drafts isolated between two FAQ rows', async () => {
711
+ const rowB = faqProposal({
712
+ id: 'issue:faq2',
713
+ issueId: 'faq2',
714
+ fingerprint: 'fp-faq2',
715
+ title: 'Roofing page lacks an FAQ section',
716
+ entity: { type: 'page', id: 'd10', title: 'Roofing', url: '/roofing' },
717
+ section: {
718
+ sectionType: 'faq',
719
+ content: {
720
+ heading: 'Roofing questions',
721
+ items: [
722
+ { question: 'Is it insured?', answer: 'Yes.' },
723
+ { question: 'Do you travel?', answer: 'Statewide.' },
724
+ { question: 'Deposit?', answer: '10%.' },
725
+ ],
726
+ },
727
+ },
728
+ grounding: null,
729
+ })
730
+ fetchSeoProposals.mockImplementationOnce(async () =>
731
+ payload({ proposals: [faqProposal(), rowB] }),
732
+ )
733
+ render(<ProposalsTab planInfo={null} />)
734
+ await screen.findAllByText('FAQ section')
735
+ fireEvent.click(
736
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
737
+ )
738
+ fireEvent.click(
739
+ screen.getByRole('button', { name: 'Show changes for Roofing page lacks an FAQ section' }),
740
+ )
741
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
742
+ expect(screen.getByRole('checkbox', { name: /Warranty\?/ })).toHaveProperty('checked', false)
743
+ for (const name of [/Is it insured\?/, /Do you travel\?/, /Deposit\?/]) {
744
+ expect(screen.getByRole('checkbox', { name })).toHaveProperty('checked', true)
745
+ }
746
+ fireEvent.click(screen.getByRole('checkbox', { name: /Deposit\?/ }))
747
+ expect(screen.getByRole('checkbox', { name: /Deposit\?/ })).toHaveProperty('checked', false)
748
+ expect(screen.getByRole('checkbox', { name: /Is it insured\?/ })).toHaveProperty(
749
+ 'checked',
750
+ true,
751
+ )
752
+ expect(screen.getByRole('checkbox', { name: /Warranty\?/ })).toHaveProperty('checked', false)
753
+ expect(screen.getByRole('checkbox', { name: /How long does it take\?/ })).toHaveProperty(
754
+ 'checked',
755
+ true,
756
+ )
757
+ })
758
+
759
+ it('starts a fresh draft when a refetch returns new questions under the same id and fingerprint', async () => {
760
+ const regenerated = faqProposal({
761
+ section: {
762
+ sectionType: 'faq',
763
+ content: {
764
+ heading: 'Common questions',
765
+ items: [
766
+ { question: 'Is it insured?', answer: 'Yes.' },
767
+ { question: 'Do you travel?', answer: 'Statewide.' },
768
+ { question: 'Deposit?', answer: '10%.' },
769
+ ],
770
+ },
771
+ },
772
+ grounding: null,
773
+ })
774
+ fetchSeoProposals
775
+ .mockImplementationOnce(async () => payload({ proposals: [proposal(), faqProposal()] }))
776
+ .mockImplementationOnce(async () => payload({ proposals: [regenerated] }))
777
+ render(<ProposalsTab planInfo={null} />)
778
+ await screen.findByText('FAQ section')
779
+ fireEvent.click(
780
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
781
+ )
782
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
783
+ fireEvent.change(screen.getByLabelText('Section heading'), {
784
+ target: { value: 'Top questions' },
785
+ })
786
+ // Dismissing another row refetches the inbox; the FAQ row comes back regenerated.
787
+ fireEvent.click(screen.getByRole('button', { name: 'Dismiss Duplicate meta title' }))
788
+ await screen.findByRole('checkbox', { name: /Deposit\?/ })
789
+ for (const name of [/Is it insured\?/, /Do you travel\?/, /Deposit\?/]) {
790
+ expect(screen.getByRole('checkbox', { name })).toHaveProperty('checked', true)
791
+ }
792
+ expect(screen.getByLabelText('Section heading')).toHaveProperty('value', 'Common questions')
793
+ expect(screen.getByText('3 of 3 questions will be added.')).toBeTruthy()
794
+ })
795
+
796
+ it('degrades when the cached section is unreadable: badge + signal, approve disabled', async () => {
797
+ fetchSeoProposals.mockImplementationOnce(async () =>
798
+ faqPayload(faqProposal({ section: null, grounding: null })),
799
+ )
800
+ render(<ProposalsTab planInfo={null} />)
801
+ await screen.findByText('FAQ section')
802
+ expect(screen.getByText(/Cited by chatgpt 14× in 28 days/)).toBeTruthy()
803
+ const rowApprove = screen.getByRole('button', {
804
+ name: 'Approve Page lacks an FAQ section',
805
+ })
806
+ expect(rowApprove).toHaveProperty('disabled', true)
807
+ expect(rowApprove.getAttribute('title')).toMatch(/could not be read/i)
808
+ expect(
809
+ document.getElementById(rowApprove.getAttribute('aria-describedby') ?? '')?.textContent,
810
+ ).toMatch(/could not be read/i)
811
+ fireEvent.click(
812
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
813
+ )
814
+ // Once in the expanded body, once as the row button's sr-only description.
815
+ expect(screen.getAllByText(/could not be read/i)).toHaveLength(2)
816
+ expect(screen.queryByRole('checkbox', { name: /Warranty\?/ })).toBeNull()
817
+ expect(screen.queryByRole('button', { name: /^Approve$/ })).toBeNull()
818
+ })
819
+
820
+ it('surfaces a server error and re-enables the controls', async () => {
821
+ fetchSeoProposals.mockImplementationOnce(async () => faqPayload())
822
+ applySeoIssueFix.mockResolvedValueOnce({ error: 'Fix suggestion is stale.' })
823
+ render(<ProposalsTab planInfo={null} />)
824
+ await screen.findByText('FAQ section')
825
+ fireEvent.click(screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' }))
826
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Fix suggestion is stale.'))
827
+ await waitFor(() =>
828
+ expect(
829
+ screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' }),
830
+ ).toHaveProperty('disabled', false),
831
+ )
832
+ expect(fetchSeoProposals).toHaveBeenCalledTimes(1)
833
+ })
834
+
835
+ it('issues a single request when approve is clicked twice while in flight', async () => {
836
+ // Persistent: the row must survive the post-approve refetch so the button can be re-read.
837
+ fetchSeoProposals.mockImplementation(async () => faqPayload())
838
+ const inflight = deferred<{ issue?: unknown; error?: string }>()
839
+ applySeoIssueFix.mockImplementationOnce(() => inflight.promise)
840
+ render(<ProposalsTab planInfo={null} />)
841
+ await screen.findByText('FAQ section')
842
+ const approve = screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' })
843
+ fireEvent.click(approve)
844
+ fireEvent.click(approve)
845
+ await flushMicrotasks()
846
+ expect(applySeoIssueFix).toHaveBeenCalledTimes(1)
847
+ expect(approve).toHaveProperty('disabled', true)
848
+ expect(
849
+ screen.getByRole('button', { name: 'Dismiss Page lacks an FAQ section' }),
850
+ ).toHaveProperty('disabled', true)
851
+ inflight.resolve({})
852
+ await waitFor(() =>
853
+ expect(
854
+ screen.getByRole('button', { name: 'Approve Page lacks an FAQ section' }),
855
+ ).toHaveProperty('disabled', false),
856
+ )
857
+ expect(applySeoIssueFix).toHaveBeenCalledTimes(1)
858
+ })
859
+
860
+ it('bulk approve still sends the whole section through bulk-apply', async () => {
861
+ fetchSeoProposals.mockImplementationOnce(async () =>
862
+ payload({ proposals: [proposal(), faqProposal()] }),
863
+ )
864
+ render(<ProposalsTab planInfo={null} />)
865
+ await screen.findByText('FAQ section')
866
+ fireEvent.click(
867
+ screen.getByRole('button', { name: 'Show changes for Page lacks an FAQ section' }),
868
+ )
869
+ fireEvent.click(screen.getByRole('checkbox', { name: /Warranty\?/ }))
870
+ fireEvent.click(screen.getByLabelText('Select all proposals'))
871
+ fireEvent.click(screen.getByRole('button', { name: 'Approve selected' }))
872
+ fireEvent.click(await screen.findByRole('button', { name: 'Apply 2 proposals' }))
873
+ await waitFor(() =>
874
+ expect(bulkApplySeoProposals).toHaveBeenCalledWith([
875
+ { kind: 'issue-fix', id: 'i1', fingerprint: 'fp-1' },
876
+ { kind: 'issue-fix', id: 'faq1', fingerprint: 'fp-faq' },
877
+ ]),
878
+ )
879
+ expect(applySeoIssueFix).not.toHaveBeenCalled()
880
+ })
881
+ })
882
+
525
883
  it('hides Generate behind the plan callout when seo.inlineApply is off', async () => {
526
884
  render(<ProposalsTab planInfo={{ tier: 'starter', features: [], upgradeUrl: null }} />)
527
885
  await screen.findByText('Duplicate meta title')
@@ -0,0 +1,134 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Expanded-row body for an `add-section` (FAQ) proposal in the SEO inbox:
5
+ * the signal that earned the page a proposal, an editable section heading,
6
+ * and one checkbox per drafted question so the reviewer can drop weak ones
7
+ * before approving. Fully controlled — the tab owns the draft.
8
+ */
9
+ import { MessageCircleQuestionMark } from 'lucide-react'
10
+ import { useId } from 'react'
11
+ import type { SeoFaqSignals } from '../../lib/seo-service.js'
12
+
13
+ /** Server-enforced floor: a FAQ section with one question is not a FAQ. */
14
+ export const FAQ_MIN_SELECTED = 2
15
+ /** Mirrors core's `FAQ_HEADING_MAX` (measured after trim). */
16
+ export const FAQ_HEADING_MAX = 80
17
+ /** The window core aggregates FAQ signals over (`FAQ_SIGNAL_WINDOW_DAYS`). */
18
+ const SIGNAL_WINDOW_DAYS = 28
19
+
20
+ export interface FaqProposalPreviewItem {
21
+ question: string
22
+ answer: string
23
+ /** Verbatim page quote the answer was grounded in; omit / `''` when none survived. */
24
+ sourceQuote?: string
25
+ }
26
+
27
+ export interface FaqProposalPreviewProps {
28
+ /** The heading the AI drafted (shown as the input's placeholder). */
29
+ heading: string
30
+ items: FaqProposalPreviewItem[]
31
+ signals: SeoFaqSignals | null
32
+ /** Controlled; parallel to `items`. */
33
+ selected: boolean[]
34
+ onToggle: (index: number) => void
35
+ /** Controlled heading field value. */
36
+ headingValue: string
37
+ onHeadingChange: (value: string) => void
38
+ disabled?: boolean
39
+ }
40
+
41
+ /**
42
+ * One-line "why this page" summary: referral citations win over retrieval
43
+ * fetches, and the top question-shaped query is appended when present.
44
+ * Engine names are rendered as the server sends them (registry display names).
45
+ */
46
+ export function faqSignalLine(signals: SeoFaqSignals | null): string | null {
47
+ if (!signals) return null
48
+ const parts: string[] = []
49
+ const g = signals.geo
50
+ const engines = g?.engines.join(', ') || 'AI engines'
51
+ if (g && g.referralVisits > 0) {
52
+ parts.push(`Cited by ${engines} ${g.referralVisits}× in ${SIGNAL_WINDOW_DAYS} days`)
53
+ } else if (g && g.retrievalHits > 0) {
54
+ parts.push(`Fetched by ${engines} ${g.retrievalHits}× in ${SIGNAL_WINDOW_DAYS} days`)
55
+ }
56
+ const q = signals.queries[0]
57
+ if (q) parts.push(`asked "${q.query}" ${q.impressions}× on Google`)
58
+ return parts.length > 0 ? parts.join(' · ') : null
59
+ }
60
+
61
+ /**
62
+ * Why the current draft cannot be approved, or `null` when it can. Shared by
63
+ * the status line below and the tab's Approve buttons so both agree. The
64
+ * heading is deliberately not a blocker: a blank field means "use the drafted
65
+ * heading" (the input's placeholder), and the tab sends no override.
66
+ */
67
+ export function faqDraftBlocker(selected: boolean[]): string | null {
68
+ const count = selected.filter(Boolean).length
69
+ if (count < FAQ_MIN_SELECTED) {
70
+ return `Select at least ${FAQ_MIN_SELECTED} questions to approve (${count} selected).`
71
+ }
72
+ return null
73
+ }
74
+
75
+ export function FaqProposalPreview(props: FaqProposalPreviewProps) {
76
+ const headingId = useId()
77
+ const selectedCount = props.selected.filter(Boolean).length
78
+ const line = faqSignalLine(props.signals)
79
+ const blocker = faqDraftBlocker(props.selected)
80
+ return (
81
+ <div className="space-y-4">
82
+ {line ? (
83
+ <p className="text-muted-foreground flex items-center gap-2 text-sm">
84
+ <MessageCircleQuestionMark size={16} aria-hidden="true" />
85
+ <span>{line}</span>
86
+ </p>
87
+ ) : null}
88
+ <div className="space-y-1">
89
+ <label htmlFor={headingId} className="text-foreground block text-sm font-medium">
90
+ Section heading
91
+ </label>
92
+ <input
93
+ id={headingId}
94
+ type="text"
95
+ value={props.headingValue}
96
+ placeholder={props.heading}
97
+ maxLength={FAQ_HEADING_MAX}
98
+ disabled={props.disabled}
99
+ onChange={(e) => props.onHeadingChange(e.target.value)}
100
+ className="bg-input-background text-foreground border-border focus-visible:ring-ring w-full max-w-md rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-60"
101
+ />
102
+ </div>
103
+ <ul className="divide-border divide-y" aria-label="Proposed questions">
104
+ {props.items.map((item, i) => {
105
+ const id = `${headingId}-item-${i}`
106
+ return (
107
+ <li key={id} className="flex gap-3 py-3">
108
+ <input
109
+ id={id}
110
+ type="checkbox"
111
+ checked={props.selected[i] ?? true}
112
+ disabled={props.disabled}
113
+ onChange={() => props.onToggle(i)}
114
+ className="border-border text-primary focus-visible:ring-ring mt-1 h-4 w-4 shrink-0 rounded"
115
+ />
116
+ <div className="min-w-0 space-y-1">
117
+ <label htmlFor={id} className="text-foreground block text-sm font-medium">
118
+ {item.question}
119
+ </label>
120
+ <p className="text-foreground text-sm">{item.answer}</p>
121
+ {item.sourceQuote ? (
122
+ <p className="text-muted-foreground text-sm">Source: “{item.sourceQuote}”</p>
123
+ ) : null}
124
+ </div>
125
+ </li>
126
+ )
127
+ })}
128
+ </ul>
129
+ <p role="status" className="text-muted-foreground text-sm">
130
+ {blocker ?? `${selectedCount} of ${props.items.length} questions will be added.`}
131
+ </p>
132
+ </div>
133
+ )
134
+ }
@@ -24,6 +24,9 @@ import { PlanUpgradeCallout } from '../PlanUpgradeCallout.js'
24
24
  import { ChangePreview } from './ChangePreview.js'
25
25
  import { SeoErrorState, btnPrimary, btnSecondary } from './primitives.js'
26
26
 
27
+ // Redirect suggestions always carry `fixStrategy: 'redirect'` (core
28
+ // `issue-fix-redirect.ts`); an undefined strategy is a metadata / schema patch
29
+ // (meta title, description, canonical, structured data, FAQPage node).
27
30
  function approveButtonLabel(strategy: SeoIssueFixSuggestion['fixStrategy']): string {
28
31
  switch (strategy) {
29
32
  case 'fix-link':
@@ -31,8 +34,9 @@ function approveButtonLabel(strategy: SeoIssueFixSuggestion['fixStrategy']): str
31
34
  case 'insert-link':
32
35
  return 'Approve link'
33
36
  case 'redirect':
34
- case undefined:
35
37
  return 'Approve redirect'
38
+ case undefined:
39
+ return 'Approve fix'
36
40
  default: {
37
41
  const _exhaustive: never = strategy
38
42
  return _exhaustive
@@ -47,8 +51,9 @@ function approveSuccessToast(strategy: SeoIssueFixSuggestion['fixStrategy']): st
47
51
  case 'insert-link':
48
52
  return 'Link added and issue resolved.'
49
53
  case 'redirect':
50
- case undefined:
51
54
  return 'Redirect created and issue resolved.'
55
+ case undefined:
56
+ return 'Fix applied and issue resolved.'
52
57
  default: {
53
58
  const _exhaustive: never = strategy
54
59
  return _exhaustive