@prenta/admin 1.13.0 → 1.14.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 +21 -0
- package/dist/__tests__/lib/seo-service-proposals.test.d.ts +2 -0
- package/dist/__tests__/lib/seo-service-proposals.test.d.ts.map +1 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js +71 -0
- package/dist/__tests__/lib/seo-service-proposals.test.js.map +1 -0
- package/dist/__tests__/router/seo-tab-for-path.test.js +1 -0
- package/dist/__tests__/router/seo-tab-for-path.test.js.map +1 -1
- package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js +292 -0
- package/dist/__tests__/views/seo-proposals-tab.render.test.js.map +1 -0
- package/dist/components/seo/ChangePreview.d.ts +6 -0
- package/dist/components/seo/ChangePreview.d.ts.map +1 -0
- package/dist/components/seo/ChangePreview.js +18 -0
- package/dist/components/seo/ChangePreview.js.map +1 -0
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +1 -14
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/lib/seo-service.d.ts +87 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +36 -0
- 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 +28 -9
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/seo/ProposalsTab.d.ts +7 -0
- package/dist/views/seo/ProposalsTab.d.ts.map +1 -0
- package/dist/views/seo/ProposalsTab.js +298 -0
- package/dist/views/seo/ProposalsTab.js.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/lib/seo-service-proposals.test.ts +79 -0
- package/src/__tests__/router/seo-tab-for-path.test.ts +1 -0
- package/src/__tests__/views/seo-proposals-tab.render.test.tsx +370 -0
- package/src/components/seo/ChangePreview.tsx +47 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +1 -44
- package/src/lib/seo-service.ts +125 -0
- package/src/views/SEO.tsx +40 -7
- package/src/views/seo/ProposalsTab.tsx +681 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
SeoProposal,
|
|
7
|
+
SeoProposalBulkApplyResult,
|
|
8
|
+
SeoProposalsPayload,
|
|
9
|
+
} from '../../lib/seo-service.js'
|
|
10
|
+
|
|
11
|
+
function proposal(over: Partial<SeoProposal> = {}): SeoProposal {
|
|
12
|
+
return {
|
|
13
|
+
id: 'issue:i1',
|
|
14
|
+
kind: 'issue-fix',
|
|
15
|
+
title: 'Duplicate meta title',
|
|
16
|
+
entity: { type: 'page', id: 'd1', title: 'Pricing', url: '/pricing' },
|
|
17
|
+
changes: [{ field: 'metaTitle', label: 'Meta title', before: 'Dup', after: 'Unique Pricing' }],
|
|
18
|
+
justification: 'AI drafted a unique title.',
|
|
19
|
+
source: 'ai',
|
|
20
|
+
confidence: null,
|
|
21
|
+
brandAlignment: 88,
|
|
22
|
+
generatedAt: new Date(Date.now() - 3600_000).toISOString(),
|
|
23
|
+
fingerprint: 'fp-1',
|
|
24
|
+
stale: false,
|
|
25
|
+
issueType: 'duplicate-meta-title',
|
|
26
|
+
severity: 'warning',
|
|
27
|
+
issueId: 'i1',
|
|
28
|
+
suggestionId: null,
|
|
29
|
+
...over,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function payload(over: Partial<SeoProposalsPayload> = {}): SeoProposalsPayload {
|
|
34
|
+
return {
|
|
35
|
+
proposals: [
|
|
36
|
+
proposal(),
|
|
37
|
+
proposal({
|
|
38
|
+
id: 'issue:i2',
|
|
39
|
+
issueId: 'i2',
|
|
40
|
+
fingerprint: 'fp-2',
|
|
41
|
+
title: 'Missing description',
|
|
42
|
+
brandAlignment: 60,
|
|
43
|
+
}),
|
|
44
|
+
proposal({
|
|
45
|
+
id: 'redirect:s1',
|
|
46
|
+
kind: 'redirect',
|
|
47
|
+
title: 'Redirect /old → /new',
|
|
48
|
+
source: 'ai-404-recovery',
|
|
49
|
+
confidence: 0.82,
|
|
50
|
+
brandAlignment: null,
|
|
51
|
+
fingerprint: null,
|
|
52
|
+
issueId: null,
|
|
53
|
+
suggestionId: 's1',
|
|
54
|
+
severity: null,
|
|
55
|
+
changes: [{ field: 'redirect', label: 'Redirect', before: '/old', after: '/new' }],
|
|
56
|
+
}),
|
|
57
|
+
],
|
|
58
|
+
counts: { issueFixes: 2, redirects: 1, stale: 0, eligibleForGeneration: 5 },
|
|
59
|
+
governance: { issueFixBulk: true, redirectBulk: true },
|
|
60
|
+
...over,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const fetchSeoProposals = vi.fn(async () => payload())
|
|
65
|
+
const generateSeoProposals = vi.fn(async () => ({
|
|
66
|
+
result: { generated: 2, skipped: 0, failed: 0, remaining: 0, haltedReason: null },
|
|
67
|
+
}))
|
|
68
|
+
const bulkApplySeoProposals = vi.fn(
|
|
69
|
+
async (_items: unknown): Promise<{ result?: SeoProposalBulkApplyResult; error?: string }> => ({
|
|
70
|
+
result: { results: [], applied: 0, failed: 0, pending: 0 },
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
const dismissSeoIssueFix = vi.fn(async (_id: string): Promise<{ error?: string }> => ({}))
|
|
74
|
+
const dismissRedirectSuggestion = vi.fn(async (_id: string): Promise<{ error?: string }> => ({}))
|
|
75
|
+
|
|
76
|
+
vi.mock('../../lib/seo-service.js', () => ({
|
|
77
|
+
fetchSeoProposals: () => fetchSeoProposals(),
|
|
78
|
+
generateSeoProposals: () => generateSeoProposals(),
|
|
79
|
+
bulkApplySeoProposals: (items: unknown) => bulkApplySeoProposals(items),
|
|
80
|
+
dismissSeoIssueFix: (id: string) => dismissSeoIssueFix(id),
|
|
81
|
+
dismissRedirectSuggestion: (id: string) => dismissRedirectSuggestion(id),
|
|
82
|
+
}))
|
|
83
|
+
vi.mock('sonner', () => ({
|
|
84
|
+
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), loading: vi.fn() },
|
|
85
|
+
}))
|
|
86
|
+
|
|
87
|
+
const { ProposalsTab } = await import('../../views/seo/ProposalsTab.js')
|
|
88
|
+
const { toast } = await import('sonner')
|
|
89
|
+
|
|
90
|
+
function deferred<T>() {
|
|
91
|
+
let resolve!: (value: T) => void
|
|
92
|
+
let reject!: (reason: unknown) => void
|
|
93
|
+
const promise = new Promise<T>((res, rej) => {
|
|
94
|
+
resolve = res
|
|
95
|
+
reject = rej
|
|
96
|
+
})
|
|
97
|
+
return { promise, resolve, reject }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Let any already-settled promise continuations run. */
|
|
101
|
+
async function flushMicrotasks() {
|
|
102
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
beforeEach(() => {
|
|
106
|
+
fetchSeoProposals.mockClear()
|
|
107
|
+
fetchSeoProposals.mockImplementation(async () => payload())
|
|
108
|
+
generateSeoProposals.mockClear()
|
|
109
|
+
bulkApplySeoProposals.mockClear()
|
|
110
|
+
dismissSeoIssueFix.mockClear()
|
|
111
|
+
dismissRedirectSuggestion.mockClear()
|
|
112
|
+
vi.mocked(toast.success).mockClear()
|
|
113
|
+
vi.mocked(toast.error).mockClear()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('ProposalsTab', () => {
|
|
117
|
+
it('renders rows with kind, badges, and expandable change preview', async () => {
|
|
118
|
+
render(<ProposalsTab planInfo={null} />)
|
|
119
|
+
expect(await screen.findByText('Duplicate meta title')).toBeTruthy()
|
|
120
|
+
expect(screen.getByText('82%')).toBeTruthy()
|
|
121
|
+
expect(screen.getByLabelText('Brand voice score 88 out of 100')).toBeTruthy()
|
|
122
|
+
fireEvent.click(screen.getByRole('button', { name: 'Show changes for Duplicate meta title' }))
|
|
123
|
+
expect(screen.getByRole('table', { name: 'Proposed SEO changes' })).toBeTruthy()
|
|
124
|
+
expect(screen.getByText('Unique Pricing')).toBeTruthy()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('select-all selects only live rows and shows the bulk bar', async () => {
|
|
128
|
+
fetchSeoProposals.mockImplementationOnce(async () =>
|
|
129
|
+
payload({
|
|
130
|
+
proposals: [
|
|
131
|
+
proposal(),
|
|
132
|
+
proposal({ id: 'issue:stale', issueId: 'stale', stale: true, title: 'Stale one' }),
|
|
133
|
+
],
|
|
134
|
+
counts: { issueFixes: 1, redirects: 0, stale: 1, eligibleForGeneration: 1 },
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
render(<ProposalsTab planInfo={null} />)
|
|
138
|
+
await screen.findByText('Stale one')
|
|
139
|
+
expect(screen.getByText('Stale — regenerate')).toBeTruthy()
|
|
140
|
+
expect(screen.queryByLabelText('Select proposal Stale one')).toBeNull()
|
|
141
|
+
fireEvent.click(screen.getByLabelText('Select all proposals'))
|
|
142
|
+
expect(screen.getByRole('region', { name: 'Bulk actions' }).textContent).toContain('1 selected')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('disables batch approve with a settings callout when governance forbids bulk apply', async () => {
|
|
146
|
+
fetchSeoProposals.mockImplementationOnce(async () =>
|
|
147
|
+
payload({ governance: { issueFixBulk: false, redirectBulk: true } }),
|
|
148
|
+
)
|
|
149
|
+
const onNavigate = vi.fn()
|
|
150
|
+
render(<ProposalsTab planInfo={null} onNavigate={onNavigate} />)
|
|
151
|
+
await screen.findByText('Duplicate meta title')
|
|
152
|
+
fireEvent.click(screen.getByLabelText('Select proposal Duplicate meta title'))
|
|
153
|
+
fireEvent.click(screen.getByLabelText('Select proposal Missing description'))
|
|
154
|
+
const approve = screen.getByRole('button', { name: 'Approve selected' })
|
|
155
|
+
expect(approve).toHaveProperty('disabled', true)
|
|
156
|
+
expect(screen.getByText(/Allow bulk apply/)).toBeTruthy()
|
|
157
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
158
|
+
fireEvent.click(screen.getByRole('button', { name: 'Open Settings → AI' }))
|
|
159
|
+
expect(onNavigate).toHaveBeenCalledWith('/settings?tab=ai')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('renders the governance callout without a settings control when onNavigate is absent', async () => {
|
|
163
|
+
fetchSeoProposals.mockImplementationOnce(async () =>
|
|
164
|
+
payload({ governance: { issueFixBulk: false, redirectBulk: true } }),
|
|
165
|
+
)
|
|
166
|
+
render(<ProposalsTab planInfo={null} />)
|
|
167
|
+
await screen.findByText('Duplicate meta title')
|
|
168
|
+
fireEvent.click(screen.getByLabelText('Select all proposals'))
|
|
169
|
+
expect(screen.getByText(/Allow bulk apply/)).toBeTruthy()
|
|
170
|
+
expect(screen.queryByRole('button', { name: 'Open Settings → AI' })).toBeNull()
|
|
171
|
+
expect(screen.queryByRole('link')).toBeNull()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('issues a single bulk-apply request when approve is triggered twice while in flight', async () => {
|
|
175
|
+
const inflight = deferred<{ result?: SeoProposalBulkApplyResult; error?: string }>()
|
|
176
|
+
bulkApplySeoProposals.mockImplementationOnce(() => inflight.promise)
|
|
177
|
+
render(<ProposalsTab planInfo={null} />)
|
|
178
|
+
await screen.findByText('Duplicate meta title')
|
|
179
|
+
const approve = screen.getByRole('button', { name: 'Approve Duplicate meta title' })
|
|
180
|
+
fireEvent.click(approve)
|
|
181
|
+
fireEvent.click(approve)
|
|
182
|
+
fireEvent.click(screen.getByRole('button', { name: 'Approve Missing description' }))
|
|
183
|
+
await flushMicrotasks()
|
|
184
|
+
expect(bulkApplySeoProposals).toHaveBeenCalledTimes(1)
|
|
185
|
+
expect(approve).toHaveProperty('disabled', true)
|
|
186
|
+
inflight.resolve({ result: { results: [], applied: 0, failed: 0, pending: 0 } })
|
|
187
|
+
await waitFor(() =>
|
|
188
|
+
expect(screen.getByRole('button', { name: 'Approve Duplicate meta title' })).toHaveProperty(
|
|
189
|
+
'disabled',
|
|
190
|
+
false,
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('disables every other mutation control while an apply is in flight', async () => {
|
|
196
|
+
const inflight = deferred<{ result?: SeoProposalBulkApplyResult; error?: string }>()
|
|
197
|
+
bulkApplySeoProposals.mockImplementationOnce(() => inflight.promise)
|
|
198
|
+
render(<ProposalsTab planInfo={null} />)
|
|
199
|
+
await screen.findByText('Duplicate meta title')
|
|
200
|
+
fireEvent.click(screen.getByLabelText('Select all proposals'))
|
|
201
|
+
fireEvent.click(screen.getByRole('button', { name: 'Approve selected' }))
|
|
202
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Apply 3 proposals' }))
|
|
203
|
+
await waitFor(() => expect(bulkApplySeoProposals).toHaveBeenCalledTimes(1))
|
|
204
|
+
expect(screen.getByRole('button', { name: 'Dismiss selected' })).toHaveProperty(
|
|
205
|
+
'disabled',
|
|
206
|
+
true,
|
|
207
|
+
)
|
|
208
|
+
expect(screen.getByRole('button', { name: 'Approve selected' })).toHaveProperty(
|
|
209
|
+
'disabled',
|
|
210
|
+
true,
|
|
211
|
+
)
|
|
212
|
+
expect(screen.getByRole('button', { name: 'Dismiss Duplicate meta title' })).toHaveProperty(
|
|
213
|
+
'disabled',
|
|
214
|
+
true,
|
|
215
|
+
)
|
|
216
|
+
expect(screen.getByRole('button', { name: 'Generate proposals (5 eligible)' })).toHaveProperty(
|
|
217
|
+
'disabled',
|
|
218
|
+
true,
|
|
219
|
+
)
|
|
220
|
+
fireEvent.click(screen.getByRole('button', { name: 'Dismiss selected' }))
|
|
221
|
+
expect(dismissSeoIssueFix).not.toHaveBeenCalled()
|
|
222
|
+
inflight.resolve({ result: { results: [], applied: 0, failed: 0, pending: 0 } })
|
|
223
|
+
await waitFor(() =>
|
|
224
|
+
expect(
|
|
225
|
+
screen.getByRole('button', { name: 'Generate proposals (5 eligible)' }),
|
|
226
|
+
).toHaveProperty('disabled', false),
|
|
227
|
+
)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('re-enables controls and toasts when the apply request throws', async () => {
|
|
231
|
+
bulkApplySeoProposals.mockRejectedValueOnce(new Error('network down'))
|
|
232
|
+
render(<ProposalsTab planInfo={null} />)
|
|
233
|
+
await screen.findByText('Duplicate meta title')
|
|
234
|
+
fireEvent.click(screen.getByRole('button', { name: 'Approve Duplicate meta title' }))
|
|
235
|
+
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('network down'))
|
|
236
|
+
await waitFor(() =>
|
|
237
|
+
expect(screen.getByRole('button', { name: 'Approve Duplicate meta title' })).toHaveProperty(
|
|
238
|
+
'disabled',
|
|
239
|
+
false,
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
expect(screen.getByRole('button', { name: 'Dismiss Duplicate meta title' })).toHaveProperty(
|
|
243
|
+
'disabled',
|
|
244
|
+
false,
|
|
245
|
+
)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('stops the generate loop and stays silent after unmount', async () => {
|
|
249
|
+
const firstPass = deferred<{
|
|
250
|
+
result: {
|
|
251
|
+
generated: number
|
|
252
|
+
skipped: number
|
|
253
|
+
failed: number
|
|
254
|
+
remaining: number
|
|
255
|
+
haltedReason: null
|
|
256
|
+
}
|
|
257
|
+
}>()
|
|
258
|
+
generateSeoProposals.mockImplementationOnce(() => firstPass.promise)
|
|
259
|
+
const { unmount } = render(<ProposalsTab planInfo={null} />)
|
|
260
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Generate proposals (5 eligible)' }))
|
|
261
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }))
|
|
262
|
+
await waitFor(() => expect(generateSeoProposals).toHaveBeenCalledTimes(1))
|
|
263
|
+
unmount()
|
|
264
|
+
firstPass.resolve({
|
|
265
|
+
result: { generated: 1, skipped: 0, failed: 0, remaining: 1, haltedReason: null },
|
|
266
|
+
})
|
|
267
|
+
await flushMicrotasks()
|
|
268
|
+
await flushMicrotasks()
|
|
269
|
+
expect(generateSeoProposals).toHaveBeenCalledTimes(1)
|
|
270
|
+
expect(toast.success).not.toHaveBeenCalled()
|
|
271
|
+
expect(toast.error).not.toHaveBeenCalled()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('confirming batch approve calls bulkApplySeoProposals with selected items and reports partial results', async () => {
|
|
275
|
+
bulkApplySeoProposals.mockResolvedValueOnce({
|
|
276
|
+
result: {
|
|
277
|
+
results: [
|
|
278
|
+
{ kind: 'issue-fix', id: 'i1', status: 'applied' },
|
|
279
|
+
{ kind: 'issue-fix', id: 'i2', status: 'stale', reason: 'stale' },
|
|
280
|
+
],
|
|
281
|
+
applied: 1,
|
|
282
|
+
failed: 1,
|
|
283
|
+
pending: 0,
|
|
284
|
+
},
|
|
285
|
+
})
|
|
286
|
+
const { toast } = await import('sonner')
|
|
287
|
+
render(<ProposalsTab planInfo={null} />)
|
|
288
|
+
await screen.findByText('Duplicate meta title')
|
|
289
|
+
fireEvent.click(screen.getByLabelText('Select proposal Duplicate meta title'))
|
|
290
|
+
fireEvent.click(screen.getByLabelText('Select proposal Missing description'))
|
|
291
|
+
fireEvent.click(screen.getByRole('button', { name: 'Approve selected' }))
|
|
292
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Apply 2 proposals' }))
|
|
293
|
+
await waitFor(() =>
|
|
294
|
+
expect(bulkApplySeoProposals).toHaveBeenCalledWith([
|
|
295
|
+
{ kind: 'issue-fix', id: 'i1', fingerprint: 'fp-1' },
|
|
296
|
+
{ kind: 'issue-fix', id: 'i2', fingerprint: 'fp-2' },
|
|
297
|
+
]),
|
|
298
|
+
)
|
|
299
|
+
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 proposal applied.'))
|
|
300
|
+
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('1 could not be applied'))
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('offers "Apply remaining" when the server reports pending items', async () => {
|
|
304
|
+
bulkApplySeoProposals.mockResolvedValueOnce({
|
|
305
|
+
result: {
|
|
306
|
+
results: [
|
|
307
|
+
{ kind: 'issue-fix', id: 'i1', status: 'applied' },
|
|
308
|
+
{ kind: 'issue-fix', id: 'i2', status: 'pending' },
|
|
309
|
+
],
|
|
310
|
+
applied: 1,
|
|
311
|
+
failed: 0,
|
|
312
|
+
pending: 1,
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
render(<ProposalsTab planInfo={null} />)
|
|
316
|
+
await screen.findByText('Duplicate meta title')
|
|
317
|
+
fireEvent.click(screen.getByLabelText('Select all proposals'))
|
|
318
|
+
fireEvent.click(screen.getByRole('button', { name: 'Approve selected' }))
|
|
319
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Apply 3 proposals' }))
|
|
320
|
+
expect(await screen.findByRole('button', { name: 'Apply remaining (1)' })).toBeTruthy()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('dismiss selected fans out to the per-kind dismiss helpers', async () => {
|
|
324
|
+
render(<ProposalsTab planInfo={null} />)
|
|
325
|
+
await screen.findByText('Duplicate meta title')
|
|
326
|
+
fireEvent.click(screen.getByLabelText('Select all proposals'))
|
|
327
|
+
fireEvent.click(screen.getByRole('button', { name: 'Dismiss selected' }))
|
|
328
|
+
await waitFor(() => expect(dismissSeoIssueFix).toHaveBeenCalledTimes(2))
|
|
329
|
+
expect(dismissRedirectSuggestion).toHaveBeenCalledWith('s1')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('generate loops while remaining > 0 and shows the eligible count', async () => {
|
|
333
|
+
generateSeoProposals
|
|
334
|
+
.mockResolvedValueOnce({
|
|
335
|
+
result: { generated: 10, skipped: 0, failed: 0, remaining: 3, haltedReason: null },
|
|
336
|
+
})
|
|
337
|
+
.mockResolvedValueOnce({
|
|
338
|
+
result: { generated: 3, skipped: 0, failed: 0, remaining: 0, haltedReason: null },
|
|
339
|
+
})
|
|
340
|
+
const { toast } = await import('sonner')
|
|
341
|
+
render(<ProposalsTab planInfo={null} />)
|
|
342
|
+
const button = await screen.findByRole('button', { name: 'Generate proposals (5 eligible)' })
|
|
343
|
+
fireEvent.click(button)
|
|
344
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Generate' }))
|
|
345
|
+
await waitFor(() => expect(generateSeoProposals).toHaveBeenCalledTimes(2))
|
|
346
|
+
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('13 proposals generated.'))
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it('disables Generate when nothing is eligible and shows the empty state', async () => {
|
|
350
|
+
fetchSeoProposals.mockImplementationOnce(async () =>
|
|
351
|
+
payload({
|
|
352
|
+
proposals: [],
|
|
353
|
+
counts: { issueFixes: 0, redirects: 0, stale: 0, eligibleForGeneration: 0 },
|
|
354
|
+
}),
|
|
355
|
+
)
|
|
356
|
+
render(<ProposalsTab planInfo={null} />)
|
|
357
|
+
expect(await screen.findByText('No proposals yet')).toBeTruthy()
|
|
358
|
+
expect(screen.getByRole('button', { name: 'Generate proposals (0 eligible)' })).toHaveProperty(
|
|
359
|
+
'disabled',
|
|
360
|
+
true,
|
|
361
|
+
)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('hides Generate behind the plan callout when seo.inlineApply is off', async () => {
|
|
365
|
+
render(<ProposalsTab planInfo={{ tier: 'starter', features: [], upgradeUrl: null }} />)
|
|
366
|
+
await screen.findByText('Duplicate meta title')
|
|
367
|
+
expect(screen.queryByRole('button', { name: /Generate proposals/ })).toBeNull()
|
|
368
|
+
expect(screen.getByText(/Upgrade to unlock SEO inline apply/)).toBeTruthy()
|
|
369
|
+
})
|
|
370
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { SeoFixFieldChange } from '../../lib/seo-service.js'
|
|
4
|
+
|
|
5
|
+
function formatValue(value: string | boolean | null): string {
|
|
6
|
+
if (value === null || value === '') return '—'
|
|
7
|
+
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
|
8
|
+
if (value.length > 280) return `${value.slice(0, 277)}…`
|
|
9
|
+
return value
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Before/after table for a proposed SEO change set. Shared by the issue fix panel and the proposals inbox. */
|
|
13
|
+
export function ChangePreview({ changes }: { changes: SeoFixFieldChange[] }) {
|
|
14
|
+
if (changes.length === 0) return null
|
|
15
|
+
return (
|
|
16
|
+
<div className="border-border overflow-x-auto rounded-md border">
|
|
17
|
+
<table className="w-full min-w-[480px] text-sm" aria-label="Proposed SEO changes">
|
|
18
|
+
<thead>
|
|
19
|
+
<tr className="border-border bg-muted/40 text-muted-foreground border-b text-left">
|
|
20
|
+
<th scope="col" className="py-2 pr-3 pl-3 font-medium">
|
|
21
|
+
Field
|
|
22
|
+
</th>
|
|
23
|
+
<th scope="col" className="py-2 pr-3 font-medium">
|
|
24
|
+
Current
|
|
25
|
+
</th>
|
|
26
|
+
<th scope="col" className="py-2 pr-3 font-medium">
|
|
27
|
+
Proposed
|
|
28
|
+
</th>
|
|
29
|
+
</tr>
|
|
30
|
+
</thead>
|
|
31
|
+
<tbody className="divide-border divide-y">
|
|
32
|
+
{changes.map((c) => (
|
|
33
|
+
<tr key={c.field}>
|
|
34
|
+
<td className="text-foreground py-2 pr-3 pl-3 font-medium">{c.label}</td>
|
|
35
|
+
<td className="text-muted-foreground max-w-48 py-2 pr-3 wrap-break-word">
|
|
36
|
+
{formatValue(c.before)}
|
|
37
|
+
</td>
|
|
38
|
+
<td className="text-foreground max-w-48 py-2 pr-3 wrap-break-word">
|
|
39
|
+
{formatValue(c.after)}
|
|
40
|
+
</td>
|
|
41
|
+
</tr>
|
|
42
|
+
))}
|
|
43
|
+
</tbody>
|
|
44
|
+
</table>
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -16,12 +16,12 @@ import {
|
|
|
16
16
|
supportsSeoContentResearch,
|
|
17
17
|
type SeoContentBrief,
|
|
18
18
|
type SeoContentResearch,
|
|
19
|
-
type SeoFixFieldChange,
|
|
20
19
|
type SeoIssue,
|
|
21
20
|
type SeoIssueFixSuggestion,
|
|
22
21
|
} from '../../lib/seo-service.js'
|
|
23
22
|
import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
|
|
24
23
|
import { PlanUpgradeCallout } from '../PlanUpgradeCallout.js'
|
|
24
|
+
import { ChangePreview } from './ChangePreview.js'
|
|
25
25
|
import { SeoErrorState, btnPrimary, btnSecondary } from './primitives.js'
|
|
26
26
|
|
|
27
27
|
function approveButtonLabel(strategy: SeoIssueFixSuggestion['fixStrategy']): string {
|
|
@@ -56,49 +56,6 @@ function approveSuccessToast(strategy: SeoIssueFixSuggestion['fixStrategy']): st
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function formatValue(value: string | boolean | null): string {
|
|
60
|
-
if (value === null || value === '') return '—'
|
|
61
|
-
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
|
62
|
-
if (value.length > 280) return `${value.slice(0, 277)}…`
|
|
63
|
-
return value
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function ChangePreview({ changes }: { changes: SeoFixFieldChange[] }) {
|
|
67
|
-
if (changes.length === 0) return null
|
|
68
|
-
return (
|
|
69
|
-
<div className="border-border overflow-x-auto rounded-md border">
|
|
70
|
-
<table className="w-full min-w-[480px] text-sm" aria-label="Proposed SEO changes">
|
|
71
|
-
<thead>
|
|
72
|
-
<tr className="border-border bg-muted/40 text-muted-foreground border-b text-left">
|
|
73
|
-
<th scope="col" className="py-2 pr-3 pl-3 font-medium">
|
|
74
|
-
Field
|
|
75
|
-
</th>
|
|
76
|
-
<th scope="col" className="py-2 pr-3 font-medium">
|
|
77
|
-
Current
|
|
78
|
-
</th>
|
|
79
|
-
<th scope="col" className="py-2 pr-3 font-medium">
|
|
80
|
-
Proposed
|
|
81
|
-
</th>
|
|
82
|
-
</tr>
|
|
83
|
-
</thead>
|
|
84
|
-
<tbody className="divide-border divide-y">
|
|
85
|
-
{changes.map((c) => (
|
|
86
|
-
<tr key={c.field}>
|
|
87
|
-
<td className="text-foreground py-2 pr-3 pl-3 font-medium">{c.label}</td>
|
|
88
|
-
<td className="text-muted-foreground max-w-48 py-2 pr-3 wrap-break-word">
|
|
89
|
-
{formatValue(c.before)}
|
|
90
|
-
</td>
|
|
91
|
-
<td className="text-foreground max-w-48 py-2 pr-3 wrap-break-word">
|
|
92
|
-
{formatValue(c.after)}
|
|
93
|
-
</td>
|
|
94
|
-
</tr>
|
|
95
|
-
))}
|
|
96
|
-
</tbody>
|
|
97
|
-
</table>
|
|
98
|
-
</div>
|
|
99
|
-
)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
59
|
function ContentResearchCard({ research }: { research: SeoContentResearch }) {
|
|
103
60
|
return (
|
|
104
61
|
<div className="border-border bg-card space-y-3 rounded-md border p-4">
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -113,6 +113,8 @@ export interface SeoOverview {
|
|
|
113
113
|
lastAuditRunId: string | null
|
|
114
114
|
/** True when the last audit hit AUDIT_MAX_ENTITIES. */
|
|
115
115
|
auditTruncated?: boolean
|
|
116
|
+
/** Cached AI issue fixes + open redirect suggestions awaiting review. */
|
|
117
|
+
pendingProposals?: { issueFixes: number; redirects: number }
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
const EMPTY_OVERVIEW: SeoOverview = {
|
|
@@ -1333,3 +1335,126 @@ export async function fetchLinkHealthIssues(): Promise<{
|
|
|
1333
1335
|
},
|
|
1334
1336
|
}
|
|
1335
1337
|
}
|
|
1338
|
+
|
|
1339
|
+
// ─── AI proposals inbox ─────────────────────────────────────────────────
|
|
1340
|
+
|
|
1341
|
+
export interface SeoProposal {
|
|
1342
|
+
/** `issue:<issueId>` | `redirect:<suggestionId>` */
|
|
1343
|
+
id: string
|
|
1344
|
+
kind: 'issue-fix' | 'redirect'
|
|
1345
|
+
title: string
|
|
1346
|
+
entity: { type: string | null; id: string | null; title: string | null; url: string | null }
|
|
1347
|
+
changes: SeoFixFieldChange[]
|
|
1348
|
+
justification: string
|
|
1349
|
+
source: 'ai' | 'deterministic' | 'ai-404-recovery'
|
|
1350
|
+
/** 0–1, redirects only. */
|
|
1351
|
+
confidence: number | null
|
|
1352
|
+
/** 0–100, issue fixes only. */
|
|
1353
|
+
brandAlignment: number | null
|
|
1354
|
+
generatedAt: string | null
|
|
1355
|
+
/** Issue fixes only; redirects are keyed by id. */
|
|
1356
|
+
fingerprint: string | null
|
|
1357
|
+
/** Issue fixes: cached fingerprint ≠ live fingerprint. Redirects: always false. */
|
|
1358
|
+
stale: boolean
|
|
1359
|
+
issueType: string | null
|
|
1360
|
+
severity: SeoSeverity | null
|
|
1361
|
+
issueId: string | null
|
|
1362
|
+
suggestionId: string | null
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
export interface SeoProposalsPayload {
|
|
1366
|
+
proposals: SeoProposal[]
|
|
1367
|
+
counts: { issueFixes: number; redirects: number; stale: number; eligibleForGeneration: number }
|
|
1368
|
+
/** `allowBulkApply` per feature, so the UI can disable batch approve up front. */
|
|
1369
|
+
governance: { issueFixBulk: boolean; redirectBulk: boolean }
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
export interface SeoProposalGenerateResult {
|
|
1373
|
+
generated: number
|
|
1374
|
+
skipped: number
|
|
1375
|
+
failed: number
|
|
1376
|
+
remaining: number
|
|
1377
|
+
haltedReason: string | null
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
export interface SeoProposalBulkApplyItem {
|
|
1381
|
+
kind: 'issue-fix' | 'redirect'
|
|
1382
|
+
id: string
|
|
1383
|
+
fingerprint?: string
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** Mirrors core's `BulkApplyItemStatus` (`seo/proposals-bulk-apply.ts`). */
|
|
1387
|
+
export type SeoProposalBulkApplyItemStatus =
|
|
1388
|
+
| 'applied'
|
|
1389
|
+
| 'stale'
|
|
1390
|
+
| 'verify-failed'
|
|
1391
|
+
| 'plan-required'
|
|
1392
|
+
| 'not-found'
|
|
1393
|
+
| 'not-allowed'
|
|
1394
|
+
| 'not-fixable'
|
|
1395
|
+
| 'invalid'
|
|
1396
|
+
| 'still-live'
|
|
1397
|
+
| 'error'
|
|
1398
|
+
| 'pending'
|
|
1399
|
+
|
|
1400
|
+
export interface SeoProposalBulkApplyResult {
|
|
1401
|
+
results: Array<{
|
|
1402
|
+
kind: 'issue-fix' | 'redirect'
|
|
1403
|
+
id: string
|
|
1404
|
+
status: SeoProposalBulkApplyItemStatus
|
|
1405
|
+
reason?: string
|
|
1406
|
+
}>
|
|
1407
|
+
applied: number
|
|
1408
|
+
failed: number
|
|
1409
|
+
/** Items the server did not reach within its time budget — resubmit exactly these. */
|
|
1410
|
+
pending: number
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
export async function fetchSeoProposals(): Promise<SeoProposalsPayload> {
|
|
1414
|
+
const res = await cmsApi<SeoProposalsPayload>('/seo/proposals')
|
|
1415
|
+
throwIfError(res)
|
|
1416
|
+
return (
|
|
1417
|
+
res.data ?? {
|
|
1418
|
+
proposals: [],
|
|
1419
|
+
counts: { issueFixes: 0, redirects: 0, stale: 0, eligibleForGeneration: 0 },
|
|
1420
|
+
governance: { issueFixBulk: false, redirectBulk: false },
|
|
1421
|
+
}
|
|
1422
|
+
)
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
export async function generateSeoProposals(): Promise<{
|
|
1426
|
+
result?: SeoProposalGenerateResult
|
|
1427
|
+
error?: string
|
|
1428
|
+
}> {
|
|
1429
|
+
const res = await cmsApi<SeoProposalGenerateResult>('/seo/proposals/generate', {
|
|
1430
|
+
method: 'POST',
|
|
1431
|
+
body: JSON.stringify({ confirm: true }),
|
|
1432
|
+
})
|
|
1433
|
+
if (res.error) return { error: res.error }
|
|
1434
|
+
return { result: res.data }
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
export async function bulkApplySeoProposals(items: SeoProposalBulkApplyItem[]): Promise<{
|
|
1438
|
+
result?: SeoProposalBulkApplyResult
|
|
1439
|
+
error?: string
|
|
1440
|
+
/** True when the server refused because `allowBulkApply` is off for a represented feature. */
|
|
1441
|
+
governanceBlocked?: boolean
|
|
1442
|
+
}> {
|
|
1443
|
+
const res = await cmsApi<SeoProposalBulkApplyResult>('/seo/proposals/bulk-apply', {
|
|
1444
|
+
method: 'POST',
|
|
1445
|
+
body: JSON.stringify({ items }),
|
|
1446
|
+
})
|
|
1447
|
+
if (res.error) {
|
|
1448
|
+
return res.code === 'governance_blocked'
|
|
1449
|
+
? { error: res.error, governanceBlocked: true }
|
|
1450
|
+
: { error: res.error }
|
|
1451
|
+
}
|
|
1452
|
+
return { result: res.data }
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
export async function dismissSeoIssueFix(issueId: string): Promise<{ error?: string }> {
|
|
1456
|
+
const res = await cmsApi(`/seo/issues/${encodeURIComponent(issueId)}/dismiss-fix`, {
|
|
1457
|
+
method: 'POST',
|
|
1458
|
+
})
|
|
1459
|
+
return res.error ? { error: res.error } : {}
|
|
1460
|
+
}
|