@prenta/admin 0.97.4 → 0.98.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/modal.render.test.js +62 -2
- package/dist/__tests__/components/modal.render.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +34 -3
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/views/seo-audit-tab.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-audit-tab.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-audit-tab.render.test.js +100 -0
- package/dist/__tests__/views/seo-audit-tab.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-technical-tab.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-technical-tab.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-technical-tab.render.test.js +121 -0
- package/dist/__tests__/views/seo-technical-tab.render.test.js.map +1 -0
- package/dist/components/ui/Modal.d.ts +8 -0
- package/dist/components/ui/Modal.d.ts.map +1 -1
- package/dist/components/ui/Modal.js +22 -1
- package/dist/components/ui/Modal.js.map +1 -1
- package/dist/lib/seo-service.d.ts +30 -1
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/seo/AuditTab.d.ts.map +1 -1
- package/dist/views/seo/AuditTab.js +3 -2
- package/dist/views/seo/AuditTab.js.map +1 -1
- package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
- package/dist/views/seo/TechnicalTab.js +5 -0
- package/dist/views/seo/TechnicalTab.js.map +1 -1
- package/package.json +7 -7
- package/src/__tests__/components/modal.render.test.tsx +109 -1
- package/src/__tests__/lib/seo-service-errors.test.ts +55 -1
- package/src/__tests__/views/seo-audit-tab.render.test.tsx +113 -0
- package/src/__tests__/views/seo-technical-tab.render.test.tsx +156 -0
- package/src/components/ui/Modal.tsx +25 -1
- package/src/lib/seo-service.ts +30 -2
- package/src/views/seo/AuditTab.tsx +60 -27
- package/src/views/seo/TechnicalTab.tsx +5 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
const baseRun = {
|
|
6
|
+
id: 'audit-1',
|
|
7
|
+
status: 'COMPLETED',
|
|
8
|
+
scope: 'full',
|
|
9
|
+
startedAt: '2026-08-11T12:00:00.000Z',
|
|
10
|
+
finishedAt: '2026-08-11T12:00:01.000Z',
|
|
11
|
+
siteScoreBefore: 70,
|
|
12
|
+
siteScoreAfter: 82,
|
|
13
|
+
pagesScanned: 10,
|
|
14
|
+
issuesFound: 3,
|
|
15
|
+
criticalCount: 0,
|
|
16
|
+
warningCount: 2,
|
|
17
|
+
infoCount: 1,
|
|
18
|
+
errorMessage: null,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let latestRun: Record<string, unknown> = { ...baseRun }
|
|
22
|
+
|
|
23
|
+
const cmsApi = vi.fn(async (endpoint: string) => {
|
|
24
|
+
if (endpoint === '/seo/audits') {
|
|
25
|
+
return { data: { runs: [latestRun] }, status: 200 }
|
|
26
|
+
}
|
|
27
|
+
if (endpoint.startsWith('/seo/issues')) {
|
|
28
|
+
return { data: { issues: [] }, status: 200 }
|
|
29
|
+
}
|
|
30
|
+
return { data: {}, status: 200 }
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
vi.mock('../../lib/api.js', () => ({
|
|
34
|
+
cmsApi: (endpoint: string) => cmsApi(endpoint),
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
vi.mock('sonner', () => ({
|
|
38
|
+
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), message: vi.fn() },
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
const { AuditTab } = await import('../../views/seo/AuditTab.js')
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
cmsApi.mockClear()
|
|
45
|
+
latestRun = { ...baseRun }
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('AuditTab rendered audit coverage', () => {
|
|
49
|
+
it('shows safe coverage details and a warning for partial runs', async () => {
|
|
50
|
+
latestRun = {
|
|
51
|
+
...baseRun,
|
|
52
|
+
metadata: {
|
|
53
|
+
renderedAudit: {
|
|
54
|
+
enabled: true,
|
|
55
|
+
originSource: 'override',
|
|
56
|
+
verified: 8,
|
|
57
|
+
unverified: 2,
|
|
58
|
+
notAttempted: 0,
|
|
59
|
+
partial: true,
|
|
60
|
+
bytesRead: 2048,
|
|
61
|
+
durationMs: 50,
|
|
62
|
+
reasons: [{ reason: 'timeout', count: 2 }],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
render(<AuditTab />)
|
|
68
|
+
|
|
69
|
+
expect(await screen.findByText('8 verified')).toBeTruthy()
|
|
70
|
+
expect(screen.getByText('2 unverified')).toBeTruthy()
|
|
71
|
+
const warning = screen.getByRole('status')
|
|
72
|
+
expect(warning.textContent).toContain('The score covers 8 verified pages.')
|
|
73
|
+
expect(warning.textContent).toContain('their prior rendered-dependent issues were preserved.')
|
|
74
|
+
expect(screen.queryByText(/timeout/i)).toBeNull()
|
|
75
|
+
expect(screen.queryByText(/https?:\/\//i)).toBeNull()
|
|
76
|
+
expect(screen.queryByText(/transport error/i)).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('does not show a partial warning for complete coverage', async () => {
|
|
80
|
+
latestRun = {
|
|
81
|
+
...baseRun,
|
|
82
|
+
metadata: {
|
|
83
|
+
renderedAudit: {
|
|
84
|
+
enabled: true,
|
|
85
|
+
originSource: 'config',
|
|
86
|
+
verified: 10,
|
|
87
|
+
unverified: 0,
|
|
88
|
+
notAttempted: 0,
|
|
89
|
+
partial: false,
|
|
90
|
+
bytesRead: 4096,
|
|
91
|
+
durationMs: 40,
|
|
92
|
+
reasons: [],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
render(<AuditTab />)
|
|
98
|
+
|
|
99
|
+
expect(await screen.findByText('10 verified')).toBeTruthy()
|
|
100
|
+
expect(screen.getByText('0 unverified')).toBeTruthy()
|
|
101
|
+
expect(screen.queryByText('Partial rendered audit')).toBeNull()
|
|
102
|
+
expect(screen.queryByRole('status')).toBeNull()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('renders legacy audit runs without coverage or warnings', async () => {
|
|
106
|
+
render(<AuditTab />)
|
|
107
|
+
|
|
108
|
+
await waitFor(() => expect(screen.getByText('10')).toBeTruthy())
|
|
109
|
+
expect(screen.queryByText(/verified$/i)).toBeNull()
|
|
110
|
+
expect(screen.queryByText('Partial rendered audit')).toBeNull()
|
|
111
|
+
expect(screen.queryByRole('status')).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
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
|
+
import { toast } from 'sonner'
|
|
5
|
+
|
|
6
|
+
const initialCrawlSettings = {
|
|
7
|
+
indexEntireSite: true,
|
|
8
|
+
includeImagesInSitemap: true,
|
|
9
|
+
includePostsInSitemap: true,
|
|
10
|
+
sitemapEnabled: true,
|
|
11
|
+
autoRegenerateSitemapOnPublish: true,
|
|
12
|
+
pingSearchEnginesOnPublish: true,
|
|
13
|
+
auditRenderedHtml: true,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let crawlSettings = { ...initialCrawlSettings }
|
|
17
|
+
let failNextPut = false
|
|
18
|
+
let pendingPut: Promise<{ data?: unknown; error?: string }> | null = null
|
|
19
|
+
|
|
20
|
+
const cmsApi = vi.fn(async (endpoint: string, options?: RequestInit) => {
|
|
21
|
+
const method = options?.method ?? 'GET'
|
|
22
|
+
if (endpoint === '/seo/crawl-settings' && method === 'GET') {
|
|
23
|
+
return { data: { ...crawlSettings }, status: 200 }
|
|
24
|
+
}
|
|
25
|
+
if (endpoint === '/seo/crawl-settings' && method === 'PUT') {
|
|
26
|
+
if (pendingPut) await pendingPut
|
|
27
|
+
if (failNextPut) {
|
|
28
|
+
failNextPut = false
|
|
29
|
+
return { error: 'Unable to save crawl settings.', status: 500 }
|
|
30
|
+
}
|
|
31
|
+
crawlSettings = {
|
|
32
|
+
...crawlSettings,
|
|
33
|
+
...(JSON.parse(String(options?.body)) as Partial<typeof initialCrawlSettings>),
|
|
34
|
+
}
|
|
35
|
+
return { data: { ...crawlSettings }, status: 200 }
|
|
36
|
+
}
|
|
37
|
+
if (endpoint === '/seo/sitemap/status') {
|
|
38
|
+
return {
|
|
39
|
+
data: {
|
|
40
|
+
sitemapUrl: 'https://example.test/sitemap.xml',
|
|
41
|
+
lastGeneratedAt: null,
|
|
42
|
+
pagesIncluded: 10,
|
|
43
|
+
pagesExcluded: 0,
|
|
44
|
+
autoRegenerateOnPublish: true,
|
|
45
|
+
errors: [],
|
|
46
|
+
warnings: [],
|
|
47
|
+
},
|
|
48
|
+
status: 200,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (endpoint === '/seo/robots') {
|
|
52
|
+
return { data: { body: 'User-agent: *\nAllow: /', generated: true }, status: 200 }
|
|
53
|
+
}
|
|
54
|
+
if (endpoint === '/ai-crawlers') {
|
|
55
|
+
return {
|
|
56
|
+
data: { enabled: false, rows: [], totals: { hits: 0, bots: 0, honeypot: 0 } },
|
|
57
|
+
status: 200,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { data: {}, status: 200 }
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
vi.mock('../../lib/api.js', () => ({
|
|
64
|
+
cmsApi: (endpoint: string, options?: RequestInit) => cmsApi(endpoint, options),
|
|
65
|
+
}))
|
|
66
|
+
|
|
67
|
+
vi.mock('sonner', () => ({
|
|
68
|
+
toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), message: vi.fn() },
|
|
69
|
+
}))
|
|
70
|
+
|
|
71
|
+
const { TechnicalTab } = await import('../../views/seo/TechnicalTab.js')
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
cmsApi.mockClear()
|
|
75
|
+
vi.mocked(toast.error).mockClear()
|
|
76
|
+
crawlSettings = { ...initialCrawlSettings }
|
|
77
|
+
failNextPut = false
|
|
78
|
+
pendingPut = null
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('TechnicalTab rendered audit setting', () => {
|
|
82
|
+
it('renders the accessible setting and persists changes', async () => {
|
|
83
|
+
render(<TechnicalTab />)
|
|
84
|
+
|
|
85
|
+
const renderedAudit = await screen.findByRole('switch', {
|
|
86
|
+
name: /Audit rendered public HTML/i,
|
|
87
|
+
description: 'Fetch public page output so audits grade crawler-visible headings and content.',
|
|
88
|
+
})
|
|
89
|
+
expect(renderedAudit.getAttribute('aria-checked')).toBe('true')
|
|
90
|
+
|
|
91
|
+
fireEvent.click(renderedAudit)
|
|
92
|
+
|
|
93
|
+
await waitFor(() =>
|
|
94
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
95
|
+
'/seo/crawl-settings',
|
|
96
|
+
expect.objectContaining({
|
|
97
|
+
method: 'PUT',
|
|
98
|
+
body: JSON.stringify({ auditRenderedHtml: false }),
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('disables crawl switches while a setting is saving', async () => {
|
|
105
|
+
let resolvePut: (() => void) | undefined
|
|
106
|
+
pendingPut = new Promise((resolve) => {
|
|
107
|
+
resolvePut = () => resolve({})
|
|
108
|
+
})
|
|
109
|
+
render(<TechnicalTab />)
|
|
110
|
+
|
|
111
|
+
fireEvent.click(await screen.findByRole('switch', { name: /Audit rendered public HTML/i }))
|
|
112
|
+
|
|
113
|
+
await waitFor(() => {
|
|
114
|
+
for (const toggle of screen.getAllByRole('switch')) {
|
|
115
|
+
expect((toggle as HTMLButtonElement).disabled).toBe(true)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
resolvePut?.()
|
|
120
|
+
await waitFor(() =>
|
|
121
|
+
expect(
|
|
122
|
+
(
|
|
123
|
+
screen.getByRole('switch', {
|
|
124
|
+
name: /Audit rendered public HTML/i,
|
|
125
|
+
}) as HTMLButtonElement
|
|
126
|
+
).disabled,
|
|
127
|
+
).toBe(false),
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('reports save failures and refetches the server value', async () => {
|
|
132
|
+
failNextPut = true
|
|
133
|
+
render(<TechnicalTab />)
|
|
134
|
+
|
|
135
|
+
const renderedAudit = await screen.findByRole('switch', {
|
|
136
|
+
name: /Audit rendered public HTML/i,
|
|
137
|
+
})
|
|
138
|
+
const getCallsBeforeSave = cmsApi.mock.calls.filter(
|
|
139
|
+
([endpoint, options]) =>
|
|
140
|
+
endpoint === '/seo/crawl-settings' &&
|
|
141
|
+
((options as RequestInit | undefined)?.method ?? 'GET') === 'GET',
|
|
142
|
+
).length
|
|
143
|
+
|
|
144
|
+
fireEvent.click(renderedAudit)
|
|
145
|
+
|
|
146
|
+
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Unable to save crawl settings.'))
|
|
147
|
+
await waitFor(() => expect(renderedAudit.getAttribute('aria-checked')).toBe('true'))
|
|
148
|
+
expect(
|
|
149
|
+
cmsApi.mock.calls.filter(
|
|
150
|
+
([endpoint, options]) =>
|
|
151
|
+
endpoint === '/seo/crawl-settings' &&
|
|
152
|
+
((options as RequestInit | undefined)?.method ?? 'GET') === 'GET',
|
|
153
|
+
).length,
|
|
154
|
+
).toBeGreaterThan(getCallsBeforeSave)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as Dialog from '@radix-ui/react-dialog'
|
|
4
4
|
import { X } from 'lucide-react'
|
|
5
|
-
import { type ReactNode, type RefObject } from 'react'
|
|
5
|
+
import { type ReactNode, type RefObject, useRef } from 'react'
|
|
6
6
|
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
7
7
|
|
|
8
8
|
const MODAL_SIZE_CLASS = {
|
|
@@ -41,6 +41,22 @@ export interface ModalProps {
|
|
|
41
41
|
onDismiss?: () => void
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* True when the event originated inside a *different* `[role="dialog"]` than
|
|
46
|
+
* `content` (e.g. MediaPickerModal / ConfirmDialog portaled above this Modal).
|
|
47
|
+
* Nested dialogs must own their own dismiss; treating their clicks as "outside"
|
|
48
|
+
* would discard the parent form before the nested selection can apply
|
|
49
|
+
* (UpChat #27 / featured-image picker).
|
|
50
|
+
*/
|
|
51
|
+
export function isNestedDialogEvent(
|
|
52
|
+
target: EventTarget | null,
|
|
53
|
+
content: EventTarget | null,
|
|
54
|
+
): boolean {
|
|
55
|
+
if (!(target instanceof Element) || !(content instanceof Element)) return false
|
|
56
|
+
const owningDialog = target.closest('[role="dialog"]')
|
|
57
|
+
return Boolean(owningDialog && owningDialog !== content)
|
|
58
|
+
}
|
|
59
|
+
|
|
44
60
|
/**
|
|
45
61
|
* The canonical admin modal. Built on Radix `Dialog`, which provides the
|
|
46
62
|
* accessibility contract for free: focus is trapped while open and restored to
|
|
@@ -64,6 +80,7 @@ export function Modal({
|
|
|
64
80
|
onDismiss,
|
|
65
81
|
}: ModalProps) {
|
|
66
82
|
const scrollBody = size === 'xl'
|
|
83
|
+
const contentRef = useRef<HTMLDivElement>(null)
|
|
67
84
|
|
|
68
85
|
function handleDismissAttempt() {
|
|
69
86
|
if (onDismiss) onDismiss()
|
|
@@ -80,8 +97,15 @@ export function Modal({
|
|
|
80
97
|
<Dialog.Portal container={adminPortalContainer()}>
|
|
81
98
|
<Dialog.Overlay className="fixed inset-0 z-60 bg-black/50" />
|
|
82
99
|
<Dialog.Content
|
|
100
|
+
ref={contentRef}
|
|
83
101
|
className={`bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none ${MODAL_SIZE_CLASS[size]}`}
|
|
84
102
|
onInteractOutside={(event) => {
|
|
103
|
+
// Radix dispatches this custom event on the outside target, so
|
|
104
|
+
// `event.currentTarget` is not the parent Dialog.Content.
|
|
105
|
+
if (isNestedDialogEvent(event.target, contentRef.current)) {
|
|
106
|
+
event.preventDefault()
|
|
107
|
+
return
|
|
108
|
+
}
|
|
85
109
|
if (!onDismiss) return
|
|
86
110
|
event.preventDefault()
|
|
87
111
|
handleDismissAttempt()
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -299,6 +299,18 @@ export function applyContentSeoQuery(
|
|
|
299
299
|
|
|
300
300
|
export type SeoAuditScope = 'full' | 'content' | 'technical' | 'redirect'
|
|
301
301
|
|
|
302
|
+
export interface RenderedAuditSummary {
|
|
303
|
+
enabled: boolean
|
|
304
|
+
originSource: 'override' | 'config' | null
|
|
305
|
+
verified: number
|
|
306
|
+
unverified: number
|
|
307
|
+
notAttempted: number
|
|
308
|
+
partial: boolean
|
|
309
|
+
bytesRead: number
|
|
310
|
+
durationMs: number
|
|
311
|
+
reasons: Array<{ reason: string; count: number }>
|
|
312
|
+
}
|
|
313
|
+
|
|
302
314
|
export interface SeoAuditRun {
|
|
303
315
|
id: string
|
|
304
316
|
status: 'RUNNING' | 'COMPLETED' | 'FAILED'
|
|
@@ -313,12 +325,27 @@ export interface SeoAuditRun {
|
|
|
313
325
|
warningCount: number
|
|
314
326
|
infoCount: number
|
|
315
327
|
errorMessage: string | null
|
|
328
|
+
metadata: {
|
|
329
|
+
renderedAudit?: RenderedAuditSummary
|
|
330
|
+
} | null
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export interface SeoAuditRunResponse {
|
|
334
|
+
id: string
|
|
335
|
+
status: 'COMPLETED'
|
|
336
|
+
siteScoreAfter: number
|
|
337
|
+
issuesFound: number
|
|
338
|
+
criticalCount: number
|
|
339
|
+
warningCount: number
|
|
340
|
+
infoCount: number
|
|
341
|
+
pagesScanned: number
|
|
342
|
+
renderedAudit: RenderedAuditSummary
|
|
316
343
|
}
|
|
317
344
|
|
|
318
345
|
export async function runSeoAudit(
|
|
319
346
|
scope: SeoAuditScope = 'full',
|
|
320
|
-
): Promise<{ run?:
|
|
321
|
-
const res = await cmsApi<
|
|
347
|
+
): Promise<{ run?: SeoAuditRunResponse; error?: string }> {
|
|
348
|
+
const res = await cmsApi<SeoAuditRunResponse>('/seo/audit', {
|
|
322
349
|
method: 'POST',
|
|
323
350
|
body: JSON.stringify({ scope }),
|
|
324
351
|
})
|
|
@@ -662,6 +689,7 @@ export interface CrawlSettings {
|
|
|
662
689
|
sitemapEnabled: boolean
|
|
663
690
|
autoRegenerateSitemapOnPublish: boolean
|
|
664
691
|
pingSearchEnginesOnPublish: boolean
|
|
692
|
+
auditRenderedHtml: boolean
|
|
665
693
|
}
|
|
666
694
|
|
|
667
695
|
export async function fetchSitemapStatus(): Promise<SitemapStatus | null> {
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { Fragment, useMemo, useState } from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
AlertTriangle,
|
|
6
|
+
ClipboardCheck,
|
|
7
|
+
Clock,
|
|
8
|
+
CheckCircle2,
|
|
9
|
+
FileDown,
|
|
10
|
+
Sparkles,
|
|
11
|
+
} from 'lucide-react'
|
|
5
12
|
import { toast } from 'sonner'
|
|
6
13
|
import {
|
|
7
14
|
fetchSeoIssues,
|
|
@@ -61,34 +68,60 @@ function issueStatusTone(status: SeoIssueStatus) {
|
|
|
61
68
|
function AuditSummaryCards({ refetchKey }: { refetchKey: number }) {
|
|
62
69
|
const { data, loading } = useSeoResource(fetchSeoAuditRuns, [refetchKey])
|
|
63
70
|
const last = (data ?? [])[0]
|
|
71
|
+
const coverage = last?.metadata?.renderedAudit
|
|
64
72
|
return (
|
|
65
|
-
<div className="
|
|
66
|
-
<div className="
|
|
67
|
-
<
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
73
|
+
<div className="space-y-4">
|
|
74
|
+
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
|
75
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
76
|
+
<p className="text-muted-foreground flex items-center gap-1.5 text-sm">
|
|
77
|
+
<Clock className="h-3.5 w-3.5" aria-hidden /> Last audit
|
|
78
|
+
</p>
|
|
79
|
+
<p className="text-foreground mt-1 text-sm font-medium">
|
|
80
|
+
{loading ? '…' : last ? new Date(last.startedAt).toLocaleString() : 'Never'}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
84
|
+
<p className="text-muted-foreground text-sm">Pages scanned</p>
|
|
85
|
+
<p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
|
|
86
|
+
{last?.pagesScanned ?? 0}
|
|
87
|
+
</p>
|
|
88
|
+
{coverage ? (
|
|
89
|
+
<p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-sm">
|
|
90
|
+
<span>{coverage.verified} verified</span>
|
|
91
|
+
<span>{coverage.unverified} unverified</span>
|
|
92
|
+
</p>
|
|
93
|
+
) : null}
|
|
94
|
+
</div>
|
|
95
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
96
|
+
<p className="text-muted-foreground text-sm">Issues found</p>
|
|
97
|
+
<p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
|
|
98
|
+
{last?.issuesFound ?? 0}
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
<div className="border-border bg-card rounded-lg border p-4">
|
|
102
|
+
<p className="text-muted-foreground text-sm">Site score</p>
|
|
103
|
+
<p className="text-foreground mt-1 text-2xl font-medium tabular-nums">
|
|
104
|
+
{last?.siteScoreAfter ?? '—'}
|
|
105
|
+
</p>
|
|
106
|
+
</div>
|
|
91
107
|
</div>
|
|
108
|
+
{coverage?.partial ? (
|
|
109
|
+
<div
|
|
110
|
+
role="status"
|
|
111
|
+
className="border-warning/30 bg-warning/10 text-foreground rounded-lg border p-4"
|
|
112
|
+
>
|
|
113
|
+
<div className="flex items-start gap-3">
|
|
114
|
+
<AlertTriangle size={20} className="text-warning shrink-0" aria-hidden="true" />
|
|
115
|
+
<div>
|
|
116
|
+
<p className="font-medium">Partial rendered audit</p>
|
|
117
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
118
|
+
The score covers {coverage.verified} verified pages. Evidence was unavailable for{' '}
|
|
119
|
+
{coverage.unverified}; their prior rendered-dependent issues were preserved.
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
) : null}
|
|
92
125
|
</div>
|
|
93
126
|
)
|
|
94
127
|
}
|
|
@@ -55,6 +55,11 @@ const CRAWL_TOGGLES: { key: keyof CrawlSettings; label: string; help: string }[]
|
|
|
55
55
|
label: 'Ping search engines on publish',
|
|
56
56
|
help: 'Notify Google and Bing when the sitemap changes (production only).',
|
|
57
57
|
},
|
|
58
|
+
{
|
|
59
|
+
key: 'auditRenderedHtml',
|
|
60
|
+
label: 'Audit rendered public HTML',
|
|
61
|
+
help: 'Fetch public page output so audits grade crawler-visible headings and content.',
|
|
62
|
+
},
|
|
58
63
|
]
|
|
59
64
|
|
|
60
65
|
function Toggle({
|