@prenta/admin 1.15.0 → 1.17.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.
- package/CHANGELOG.md +16 -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 +3 -0
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-geo.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-geo.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-geo.test.js +103 -0
- package/dist/__tests__/lib/seo-service-geo.test.js.map +1 -0
- 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__/router/seo-tab-for-path.test.js +4 -0
- package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
- package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.js +244 -0
- package/dist/__tests__/views/seo-ai-visibility.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.js +227 -0
- package/dist/__tests__/views/seo-geo-settings-card.render.test.js.map +1 -0
- 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/__tests__/views/seo-settings.render.test.js +16 -4
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-technical-tab.render.test.js +13 -0
- package/dist/__tests__/views/seo-technical-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 +155 -2
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +42 -2
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/SEO.d.ts +5 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +12 -7
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/seo/AiVisibilityTab.d.ts +4 -0
- package/dist/views/seo/AiVisibilityTab.d.ts.map +1 -0
- package/dist/views/seo/AiVisibilityTab.js +165 -0
- package/dist/views/seo/AiVisibilityTab.js.map +1 -0
- 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/seo/TechnicalTab.d.ts +3 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +4 -4
- package/dist/views/seo/TechnicalTab.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/dist/views/settings/SeoGeoSettingsCard.d.ts +12 -0
- package/dist/views/settings/SeoGeoSettingsCard.d.ts.map +1 -0
- package/dist/views/settings/SeoGeoSettingsCard.js +178 -0
- package/dist/views/settings/SeoGeoSettingsCard.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +2 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/package.json +3 -3
- 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 +4 -0
- package/src/__tests__/lib/seo-service-geo.test.ts +118 -0
- package/src/__tests__/lib/seo-service-proposals.test.ts +30 -0
- package/src/__tests__/router/seo-tab-for-path.test.ts +5 -0
- package/src/__tests__/views/seo-ai-visibility.render.test.tsx +279 -0
- package/src/__tests__/views/seo-geo-settings-card.render.test.tsx +253 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +358 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +28 -7
- package/src/__tests__/views/seo-technical-tab.render.test.tsx +15 -0
- package/src/components/seo/FaqProposalPreview.tsx +134 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +7 -2
- package/src/lib/seo-service.ts +162 -1
- package/src/views/SEO.tsx +15 -6
- package/src/views/seo/AiVisibilityTab.tsx +616 -0
- package/src/views/seo/ProposalsTab.tsx +279 -13
- package/src/views/seo/TechnicalTab.tsx +27 -8
- package/src/views/settings/SeoAutopilotCard.tsx +6 -0
- package/src/views/settings/SeoGeoSettingsCard.tsx +310 -0
- package/src/views/settings/SeoSettingsTab.tsx +2 -0
|
@@ -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')
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
3
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
4
4
|
|
|
5
|
-
// The SEO defaults tab reads
|
|
5
|
+
// The SEO defaults tab reads eight shared backends: /seo/config (meta defaults +
|
|
6
6
|
// verification), /seo/crawl-settings (sitemap toggles), /seo/sitemap/status
|
|
7
7
|
// (read-only summary), /seo/index-status (environment), /seo/search-console
|
|
8
|
-
// (Search data card), /seo/
|
|
9
|
-
// /seo/
|
|
8
|
+
// (Search data card), /seo/geo-settings (AI visibility card),
|
|
9
|
+
// /seo/autopilot/settings (SEO Autopilot card), and /seo/digest-settings
|
|
10
|
+
// (Weekly SEO digest card).
|
|
10
11
|
// `defaultApi` is the per-path route map; individual tests override the mock
|
|
11
12
|
// implementation and delegate back to it for unrelated paths.
|
|
12
13
|
type MockApiResponse = { data?: unknown; error?: string; status: number }
|
|
@@ -75,6 +76,9 @@ async function defaultApi(
|
|
|
75
76
|
status: 200,
|
|
76
77
|
}
|
|
77
78
|
}
|
|
79
|
+
if (endpoint === '/seo/geo-settings' && method === 'GET') {
|
|
80
|
+
return { data: { enabled: true, retentionDays: 90 }, status: 200 }
|
|
81
|
+
}
|
|
78
82
|
if (endpoint === '/seo/digest-settings' && method === 'GET') {
|
|
79
83
|
return {
|
|
80
84
|
data: { enabled: false, recipients: [], lastSentAt: null, lastError: null },
|
|
@@ -355,23 +359,40 @@ describe('SeoSettingsTab', () => {
|
|
|
355
359
|
expect(body.serviceAccountKey).toContain('svc@acme.iam.gserviceaccount.com')
|
|
356
360
|
})
|
|
357
361
|
|
|
358
|
-
it('renders
|
|
362
|
+
it('renders Search data, AI visibility, SEO Autopilot, and the digest in that order', async () => {
|
|
359
363
|
render(<SeoSettingsTab role="ADMIN" />)
|
|
360
|
-
//
|
|
361
|
-
// that only
|
|
364
|
+
// Titles also render in the cards' skeleton states; wait for controls
|
|
365
|
+
// that only exist once each card's settings have loaded.
|
|
362
366
|
const toggle = await screen.findByRole('switch', { name: 'Enable autopilot' })
|
|
363
367
|
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
|
368
|
+
const geoToggle = await screen.findByRole('switch', {
|
|
369
|
+
name: 'Record AI crawler and referral activity',
|
|
370
|
+
})
|
|
371
|
+
expect(geoToggle.getAttribute('aria-checked')).toBe('true')
|
|
364
372
|
const regions = screen
|
|
365
373
|
.getAllByRole('region')
|
|
366
374
|
.map((r) => r.getAttribute('aria-labelledby'))
|
|
367
375
|
.map((id) => (id ? document.getElementById(id)?.textContent : null))
|
|
368
|
-
const order = ['Search data', 'SEO Autopilot', 'Weekly SEO digest'].map((t) =>
|
|
376
|
+
const order = ['Search data', 'AI visibility', 'SEO Autopilot', 'Weekly SEO digest'].map((t) =>
|
|
369
377
|
regions.indexOf(t),
|
|
370
378
|
)
|
|
371
379
|
expect(order.every((i) => i >= 0)).toBe(true)
|
|
372
380
|
expect(order).toEqual([...order].sort((a, b) => a - b))
|
|
373
381
|
})
|
|
374
382
|
|
|
383
|
+
it('PUTs the AI visibility kill switch to /seo/geo-settings', async () => {
|
|
384
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
385
|
+
fireEvent.click(
|
|
386
|
+
await screen.findByRole('switch', { name: 'Record AI crawler and referral activity' }),
|
|
387
|
+
)
|
|
388
|
+
await waitFor(() =>
|
|
389
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
390
|
+
'/seo/geo-settings',
|
|
391
|
+
expect.objectContaining({ method: 'PUT', body: JSON.stringify({ enabled: false }) }),
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
})
|
|
395
|
+
|
|
375
396
|
it('renders the Weekly SEO digest card with the toggle off by default', async () => {
|
|
376
397
|
render(<SeoSettingsTab role="ADMIN" />)
|
|
377
398
|
expect(await screen.findByText('Weekly SEO digest')).toBeTruthy()
|
|
@@ -78,6 +78,21 @@ beforeEach(() => {
|
|
|
78
78
|
pendingPut = null
|
|
79
79
|
})
|
|
80
80
|
|
|
81
|
+
describe('TechnicalTab AI crawler card', () => {
|
|
82
|
+
it('links to the AI visibility tab via onNavigate', async () => {
|
|
83
|
+
const onNavigate = vi.fn()
|
|
84
|
+
render(<TechnicalTab onNavigate={onNavigate} />)
|
|
85
|
+
fireEvent.click(await screen.findByRole('button', { name: /view trends/i }))
|
|
86
|
+
expect(onNavigate).toHaveBeenCalledWith('/seo/ai-visibility')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('omits the trends link when the host gives no navigator', async () => {
|
|
90
|
+
render(<TechnicalTab />)
|
|
91
|
+
await screen.findByRole('switch', { name: /Audit rendered public HTML/i })
|
|
92
|
+
expect(screen.queryByRole('button', { name: /view trends/i })).toBeNull()
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
81
96
|
describe('TechnicalTab rendered audit setting', () => {
|
|
82
97
|
it('renders the accessible setting and persists changes', async () => {
|
|
83
98
|
render(<TechnicalTab />)
|
|
@@ -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
|