@prenta/admin 1.12.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 +43 -0
- package/dist/__tests__/lib/seo-service-errors.test.js +34 -1
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- 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/__tests__/views/seo-settings.render.test.js +73 -6
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- 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 +106 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +67 -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/dist/views/settings/SeoDigestCard.d.ts +9 -0
- package/dist/views/settings/SeoDigestCard.d.ts.map +1 -0
- package/dist/views/settings/SeoDigestCard.js +77 -0
- package/dist/views/settings/SeoDigestCard.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__/lib/seo-service-errors.test.ts +38 -0
- 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/__tests__/views/seo-settings.render.test.tsx +104 -5
- package/src/components/seo/ChangePreview.tsx +47 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +1 -44
- package/src/lib/seo-service.ts +168 -0
- package/src/views/SEO.tsx +40 -7
- package/src/views/seo/ProposalsTab.tsx +681 -0
- package/src/views/settings/SeoDigestCard.tsx +164 -0
- package/src/views/settings/SeoSettingsTab.tsx +2 -0
|
@@ -2,11 +2,18 @@
|
|
|
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 six shared backends: /seo/config (meta defaults +
|
|
6
6
|
// verification), /seo/crawl-settings (sitemap toggles), /seo/sitemap/status
|
|
7
|
-
// (read-only summary), /seo/index-status (environment),
|
|
8
|
-
// /seo/
|
|
9
|
-
|
|
7
|
+
// (read-only summary), /seo/index-status (environment), /seo/search-console
|
|
8
|
+
// (Search data card), and /seo/digest-settings (Weekly SEO digest card).
|
|
9
|
+
// `defaultApi` is the per-path route map; individual tests override the mock
|
|
10
|
+
// implementation and delegate back to it for unrelated paths.
|
|
11
|
+
type MockApiResponse = { data?: unknown; error?: string; status: number }
|
|
12
|
+
|
|
13
|
+
async function defaultApi(
|
|
14
|
+
endpoint: string,
|
|
15
|
+
options?: { method?: string },
|
|
16
|
+
): Promise<MockApiResponse> {
|
|
10
17
|
const method = options?.method ?? 'GET'
|
|
11
18
|
if (endpoint === '/seo/config' && method === 'GET') {
|
|
12
19
|
return {
|
|
@@ -67,14 +74,34 @@ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) =>
|
|
|
67
74
|
status: 200,
|
|
68
75
|
}
|
|
69
76
|
}
|
|
77
|
+
if (endpoint === '/seo/digest-settings' && method === 'GET') {
|
|
78
|
+
return {
|
|
79
|
+
data: { enabled: false, recipients: [], lastSentAt: null, lastError: null },
|
|
80
|
+
status: 200,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
70
83
|
return { data: {}, status: 200 }
|
|
71
|
-
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const cmsApi = vi.fn(defaultApi)
|
|
72
87
|
|
|
73
88
|
vi.mock('../../lib/api.js', () => ({
|
|
74
89
|
cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any),
|
|
75
90
|
ensureCsrfToken: vi.fn(async () => {}),
|
|
76
91
|
}))
|
|
77
92
|
|
|
93
|
+
// Record toast calls so tests can assert server errors are surfaced verbatim.
|
|
94
|
+
const toastSuccess = vi.fn()
|
|
95
|
+
const toastError = vi.fn()
|
|
96
|
+
vi.mock('sonner', () => ({
|
|
97
|
+
toast: {
|
|
98
|
+
success: (...args: unknown[]) => toastSuccess(...args),
|
|
99
|
+
error: (...args: unknown[]) => toastError(...args),
|
|
100
|
+
warning: vi.fn(),
|
|
101
|
+
loading: vi.fn(),
|
|
102
|
+
},
|
|
103
|
+
}))
|
|
104
|
+
|
|
78
105
|
// Stub the Media Center picker: when open, expose a button that selects a known
|
|
79
106
|
// asset (id + url), exercising the asset-id storage path without the real
|
|
80
107
|
// MediaBrowser/network stack.
|
|
@@ -105,6 +132,9 @@ const { SeoSettingsTab } = await import('../../views/settings/SeoSettingsTab.js'
|
|
|
105
132
|
|
|
106
133
|
beforeEach(() => {
|
|
107
134
|
cmsApi.mockClear()
|
|
135
|
+
cmsApi.mockImplementation(defaultApi)
|
|
136
|
+
toastSuccess.mockClear()
|
|
137
|
+
toastError.mockClear()
|
|
108
138
|
})
|
|
109
139
|
|
|
110
140
|
describe('SeoSettingsTab', () => {
|
|
@@ -300,4 +330,73 @@ describe('SeoSettingsTab', () => {
|
|
|
300
330
|
expect(body.propertyUrl).toBe('sc-domain:acme.test')
|
|
301
331
|
expect(body.serviceAccountKey).toContain('svc@acme.iam.gserviceaccount.com')
|
|
302
332
|
})
|
|
333
|
+
|
|
334
|
+
it('renders the Weekly SEO digest card with the toggle off by default', async () => {
|
|
335
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
336
|
+
expect(await screen.findByText('Weekly SEO digest')).toBeTruthy()
|
|
337
|
+
|
|
338
|
+
const toggle = screen.getByRole('switch', { name: 'Send weekly digest' })
|
|
339
|
+
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
|
340
|
+
expect(screen.getByText('Never sent')).toBeTruthy()
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('saves recipients as a trimmed, comma-split list', async () => {
|
|
344
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
345
|
+
await screen.findByText('Weekly SEO digest')
|
|
346
|
+
|
|
347
|
+
fireEvent.change(screen.getByLabelText('Recipients'), {
|
|
348
|
+
target: { value: ' a@x.com , b@x.com ' },
|
|
349
|
+
})
|
|
350
|
+
fireEvent.click(screen.getByRole('button', { name: 'Save recipients' }))
|
|
351
|
+
|
|
352
|
+
await waitFor(() =>
|
|
353
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
354
|
+
'/seo/digest-settings',
|
|
355
|
+
expect.objectContaining({ method: 'PUT' }),
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
const putCall = cmsApi.mock.calls.find(
|
|
359
|
+
([e, o]) => e === '/seo/digest-settings' && (o as { method?: string })?.method === 'PUT',
|
|
360
|
+
)
|
|
361
|
+
const body = JSON.parse((putCall![1] as { body: string }).body)
|
|
362
|
+
expect(body.recipients).toEqual(['a@x.com', 'b@x.com'])
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('surfaces the server error verbatim when test-send fails', async () => {
|
|
366
|
+
cmsApi.mockImplementation(async (endpoint, options) => {
|
|
367
|
+
if (endpoint === '/seo/digest-settings/test-send' && options?.method === 'POST') {
|
|
368
|
+
return { error: 'No completed SEO audit yet — run an audit first', status: 409 }
|
|
369
|
+
}
|
|
370
|
+
return defaultApi(endpoint, options)
|
|
371
|
+
})
|
|
372
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
373
|
+
await screen.findByText('Weekly SEO digest')
|
|
374
|
+
|
|
375
|
+
fireEvent.click(screen.getByRole('button', { name: /Send test digest/ }))
|
|
376
|
+
|
|
377
|
+
await waitFor(() =>
|
|
378
|
+
expect(toastError).toHaveBeenCalledWith('No completed SEO audit yet — run an audit first'),
|
|
379
|
+
)
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('shows the last error from settings in destructive tone', async () => {
|
|
383
|
+
cmsApi.mockImplementation(async (endpoint, options) => {
|
|
384
|
+
if (endpoint === '/seo/digest-settings' && (options?.method ?? 'GET') === 'GET') {
|
|
385
|
+
return {
|
|
386
|
+
data: {
|
|
387
|
+
enabled: true,
|
|
388
|
+
recipients: ['owner@acme.test'],
|
|
389
|
+
lastSentAt: '2026-09-01T04:00:00Z',
|
|
390
|
+
lastError: '1/2 digest sends failed',
|
|
391
|
+
},
|
|
392
|
+
status: 200,
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return defaultApi(endpoint, options)
|
|
396
|
+
})
|
|
397
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
398
|
+
|
|
399
|
+
const lastError = await screen.findByText(/1\/2 digest sends failed/)
|
|
400
|
+
expect(lastError.className).toContain('text-destructive')
|
|
401
|
+
})
|
|
303
402
|
})
|
|
@@ -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 = {
|
|
@@ -783,6 +785,49 @@ export async function updateCrawlSettings(
|
|
|
783
785
|
return res.error ? { error: res.error } : {}
|
|
784
786
|
}
|
|
785
787
|
|
|
788
|
+
// ─── Weekly digest ───────────────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
export interface SeoDigestSettings {
|
|
791
|
+
enabled: boolean
|
|
792
|
+
recipients: string[]
|
|
793
|
+
lastSentAt: string | null
|
|
794
|
+
lastError: string | null
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export async function fetchDigestSettings(): Promise<SeoDigestSettings | null> {
|
|
798
|
+
const res = await cmsApi<SeoDigestSettings>('/seo/digest-settings')
|
|
799
|
+
const d = res.data
|
|
800
|
+
// Validate the shape before handing it to the UI — an unexpected response
|
|
801
|
+
// body must render the card's error state, not crash on `recipients.join`.
|
|
802
|
+
if (!d || typeof d.enabled !== 'boolean' || !Array.isArray(d.recipients)) return null
|
|
803
|
+
return {
|
|
804
|
+
enabled: d.enabled,
|
|
805
|
+
recipients: d.recipients,
|
|
806
|
+
lastSentAt: d.lastSentAt ?? null,
|
|
807
|
+
lastError: d.lastError ?? null,
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export async function updateDigestSettings(patch: {
|
|
812
|
+
enabled?: boolean
|
|
813
|
+
recipients?: string[]
|
|
814
|
+
}): Promise<{ ok: boolean; error?: string }> {
|
|
815
|
+
const res = await cmsApi<{ ok: boolean }>('/seo/digest-settings', {
|
|
816
|
+
method: 'PUT',
|
|
817
|
+
body: JSON.stringify(patch),
|
|
818
|
+
})
|
|
819
|
+
if (res.error) return { ok: false, error: res.error }
|
|
820
|
+
return { ok: true }
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export async function sendTestDigest(): Promise<{ ok: boolean; to?: string; error?: string }> {
|
|
824
|
+
const res = await cmsApi<{ ok: boolean; to?: string }>('/seo/digest-settings/test-send', {
|
|
825
|
+
method: 'POST',
|
|
826
|
+
})
|
|
827
|
+
if (res.error) return { ok: false, error: res.error }
|
|
828
|
+
return { ok: true, to: res.data?.to }
|
|
829
|
+
}
|
|
830
|
+
|
|
786
831
|
// ─── AI crawler activity ─────────────────────────────────────────────
|
|
787
832
|
|
|
788
833
|
export interface AiCrawlerRow {
|
|
@@ -1290,3 +1335,126 @@ export async function fetchLinkHealthIssues(): Promise<{
|
|
|
1290
1335
|
},
|
|
1291
1336
|
}
|
|
1292
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
|
+
}
|
package/src/views/SEO.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* SEO Operations Center —
|
|
5
|
-
* Technical / Redirects / Audit). Tab + deep-link state is
|
|
6
|
-
* `?tab=` query string so audits, issues, redirects,
|
|
7
|
-
* all linkable. All data flows through
|
|
4
|
+
* SEO Operations Center — seven-tab workflow surface (Overview / Content /
|
|
5
|
+
* Links / Technical / Redirects / Audit / Proposals). Tab + deep-link state is
|
|
6
|
+
* driven by the `?tab=` query string so audits, issues, redirects, proposals,
|
|
7
|
+
* and content editors are all linkable. All data flows through
|
|
8
|
+
* `lib/seo-service.ts`.
|
|
8
9
|
*/
|
|
9
10
|
import * as Tabs from '@radix-ui/react-tabs'
|
|
10
11
|
import {
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
RefreshCw,
|
|
17
18
|
Bot,
|
|
18
19
|
Link2,
|
|
20
|
+
Inbox,
|
|
19
21
|
} from 'lucide-react'
|
|
20
22
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
21
23
|
import { toast } from 'sonner'
|
|
@@ -32,6 +34,7 @@ import { TechnicalTab } from './seo/TechnicalTab.js'
|
|
|
32
34
|
import { RedirectsTab } from './seo/RedirectsTab.js'
|
|
33
35
|
import { AuditTab } from './seo/AuditTab.js'
|
|
34
36
|
import { LinksTab } from './seo/LinksTab.js'
|
|
37
|
+
import { ProposalsTab } from './seo/ProposalsTab.js'
|
|
35
38
|
|
|
36
39
|
export interface SEOProps {
|
|
37
40
|
onNavigate?: (path: string) => void
|
|
@@ -45,6 +48,7 @@ const TABS = [
|
|
|
45
48
|
{ id: 'technical', label: 'Technical', icon: Settings2 },
|
|
46
49
|
{ id: 'redirects', label: 'Redirects', icon: ArrowRightLeft },
|
|
47
50
|
{ id: 'audit', label: 'Audit', icon: ClipboardCheck },
|
|
51
|
+
{ id: 'proposals', label: 'Proposals', icon: Inbox },
|
|
48
52
|
] as const
|
|
49
53
|
|
|
50
54
|
type TabId = (typeof TABS)[number]['id']
|
|
@@ -69,6 +73,7 @@ export const SEO_DEEP_PATHS = [
|
|
|
69
73
|
'/seo/technical',
|
|
70
74
|
'/seo/links',
|
|
71
75
|
'/seo/audit',
|
|
76
|
+
'/seo/proposals',
|
|
72
77
|
'/seo',
|
|
73
78
|
] as const
|
|
74
79
|
|
|
@@ -89,6 +94,8 @@ export function seoTabForPath(pathname: string): TabId | null {
|
|
|
89
94
|
return 'technical'
|
|
90
95
|
case '/seo/audit':
|
|
91
96
|
return 'audit'
|
|
97
|
+
case '/seo/proposals':
|
|
98
|
+
return 'proposals'
|
|
92
99
|
case '/seo':
|
|
93
100
|
return 'overview'
|
|
94
101
|
default:
|
|
@@ -177,7 +184,11 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
|
|
|
177
184
|
|
|
178
185
|
const badgeCounts = useMemo(() => {
|
|
179
186
|
const summary = overviewResource.data?.issuesSummary
|
|
180
|
-
|
|
187
|
+
const pending = overviewResource.data?.pendingProposals
|
|
188
|
+
return {
|
|
189
|
+
audit: summary ? summary.critical + summary.warning : 0,
|
|
190
|
+
proposals: pending ? pending.issueFixes + pending.redirects : 0,
|
|
191
|
+
}
|
|
181
192
|
}, [overviewResource.data])
|
|
182
193
|
|
|
183
194
|
return (
|
|
@@ -215,7 +226,18 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
|
|
|
215
226
|
>
|
|
216
227
|
{TABS.map((t) => {
|
|
217
228
|
const Icon = t.icon
|
|
218
|
-
const badge =
|
|
229
|
+
const badge =
|
|
230
|
+
t.id === 'audit'
|
|
231
|
+
? badgeCounts.audit
|
|
232
|
+
: t.id === 'proposals'
|
|
233
|
+
? badgeCounts.proposals
|
|
234
|
+
: 0
|
|
235
|
+
// The proposals count is informational (items awaiting review),
|
|
236
|
+
// not a warning like the audit issue count.
|
|
237
|
+
const badgeClass =
|
|
238
|
+
t.id === 'proposals'
|
|
239
|
+
? 'bg-muted text-muted-foreground'
|
|
240
|
+
: 'bg-destructive/10 text-destructive'
|
|
219
241
|
return (
|
|
220
242
|
<Tabs.Trigger
|
|
221
243
|
key={t.id}
|
|
@@ -225,7 +247,9 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
|
|
|
225
247
|
<Icon className="h-4 w-4" aria-hidden />
|
|
226
248
|
{t.label}
|
|
227
249
|
{badge > 0 && (
|
|
228
|
-
<span
|
|
250
|
+
<span
|
|
251
|
+
className={`${badgeClass} ml-1 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums`}
|
|
252
|
+
>
|
|
229
253
|
{badge}
|
|
230
254
|
</span>
|
|
231
255
|
)}
|
|
@@ -272,6 +296,15 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
|
|
|
272
296
|
/>
|
|
273
297
|
)}
|
|
274
298
|
</Tabs.Content>
|
|
299
|
+
<Tabs.Content value="proposals" tabIndex={-1}>
|
|
300
|
+
{activeTab === 'proposals' && (
|
|
301
|
+
<ProposalsTab
|
|
302
|
+
planInfo={planInfo}
|
|
303
|
+
onChanged={() => setRefetchKey((k) => k + 1)}
|
|
304
|
+
onNavigate={onNavigate}
|
|
305
|
+
/>
|
|
306
|
+
)}
|
|
307
|
+
</Tabs.Content>
|
|
275
308
|
</ErrorBoundary>
|
|
276
309
|
</div>
|
|
277
310
|
</Tabs.Root>
|