@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.
- package/CHANGELOG.md +18 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts +2 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.js +97 -0
- package/dist/__tests__/components/faq-proposal-preview.render.test.js.map +1 -0
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +37 -11
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +5 -6
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-proposals.test.js +24 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -1
- package/dist/__tests__/views/seo-proposals-tab.render.test.js +277 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -1
- package/dist/components/seo/FaqProposalPreview.d.ts +39 -0
- package/dist/components/seo/FaqProposalPreview.d.ts.map +1 -0
- package/dist/components/seo/FaqProposalPreview.js +62 -0
- package/dist/components/seo/FaqProposalPreview.js.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +7 -2
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/lib/seo-service.d.ts +61 -7
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +9 -13
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -1
- package/dist/views/seo/ProposalsTab.js +160 -7
- package/dist/views/seo/ProposalsTab.js.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.d.ts.map +1 -1
- package/dist/views/settings/SeoAutopilotCard.js +6 -0
- package/dist/views/settings/SeoAutopilotCard.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/components/faq-proposal-preview.render.test.tsx +137 -0
- package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +42 -11
- package/src/__tests__/lib/seo-service-errors.test.ts +5 -9
- package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
- package/src/components/seo/FaqProposalPreview.tsx +134 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
- package/src/lib/seo-service.ts +57 -15
- package/src/views/seo/ProposalsTab.tsx +279 -13
- package/src/views/settings/SeoAutopilotCard.tsx +6 -0
- package/dist/components/ContentOverviewChart.d.ts +0 -8
- package/dist/components/ContentOverviewChart.d.ts.map +0 -1
- package/dist/components/ContentOverviewChart.js +0 -20
- package/dist/components/ContentOverviewChart.js.map +0 -1
- package/dist/components/SEOPerformance.d.ts +0 -5
- package/dist/components/SEOPerformance.d.ts.map +0 -1
- package/dist/components/SEOPerformance.js +0 -29
- package/dist/components/SEOPerformance.js.map +0 -1
- package/dist/components/ui/Avatar.d.ts +0 -9
- package/dist/components/ui/Avatar.d.ts.map +0 -1
- package/dist/components/ui/Avatar.js +0 -21
- package/dist/components/ui/Avatar.js.map +0 -1
- package/dist/components/ui/CommandPalette.d.ts +0 -14
- package/dist/components/ui/CommandPalette.d.ts.map +0 -1
- package/dist/components/ui/CommandPalette.js +0 -58
- package/dist/components/ui/CommandPalette.js.map +0 -1
- package/dist/components/ui/DataTable.d.ts +0 -30
- package/dist/components/ui/DataTable.d.ts.map +0 -1
- package/dist/components/ui/DataTable.js +0 -44
- package/dist/components/ui/DataTable.js.map +0 -1
- package/dist/components/ui/Pagination.d.ts +0 -9
- package/dist/components/ui/Pagination.d.ts.map +0 -1
- package/dist/components/ui/Pagination.js +0 -26
- package/dist/components/ui/Pagination.js.map +0 -1
- package/dist/components/ui/Select.d.ts +0 -16
- package/dist/components/ui/Select.d.ts.map +0 -1
- package/dist/components/ui/Select.js +0 -24
- package/dist/components/ui/Select.js.map +0 -1
- package/dist/components/ui/Toast.d.ts +0 -18
- package/dist/components/ui/Toast.d.ts.map +0 -1
- package/dist/components/ui/Toast.js +0 -29
- package/dist/components/ui/Toast.js.map +0 -1
- package/dist/components/ui/index.d.ts +0 -46
- package/dist/components/ui/index.d.ts.map +0 -1
- package/dist/components/ui/index.js +0 -24
- package/dist/components/ui/index.js.map +0 -1
- package/dist/hooks/useContentLock.d.ts +0 -12
- package/dist/hooks/useContentLock.d.ts.map +0 -1
- package/dist/hooks/useContentLock.js +0 -38
- package/dist/hooks/useContentLock.js.map +0 -1
- package/dist/hooks/useDebounce.d.ts +0 -2
- package/dist/hooks/useDebounce.d.ts.map +0 -1
- package/dist/hooks/useDebounce.js +0 -11
- package/dist/hooks/useDebounce.js.map +0 -1
- package/src/components/ContentOverviewChart.tsx +0 -75
- package/src/components/SEOPerformance.tsx +0 -163
- package/src/components/ui/Avatar.tsx +0 -42
- package/src/components/ui/CommandPalette.tsx +0 -133
- package/src/components/ui/DataTable.tsx +0 -204
- package/src/components/ui/Pagination.tsx +0 -94
- package/src/components/ui/Select.tsx +0 -53
- package/src/components/ui/Toast.tsx +0 -72
- package/src/components/ui/index.ts +0 -61
- package/src/hooks/useContentLock.ts +0 -55
- 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
|