@prenta/admin 1.11.0 → 1.13.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 +48 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.js +15 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +87 -1
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/views/ai-settings.render.test.js +26 -8
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +20 -2
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +110 -5
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoCopilotDrawer.js +4 -0
- package/dist/components/seo/SeoCopilotDrawer.js.map +1 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +8 -1
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixDrawer.js +4 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -1
- package/dist/components/seo/SeoSearchStats.d.ts +4 -0
- package/dist/components/seo/SeoSearchStats.d.ts.map +1 -0
- package/dist/components/seo/SeoSearchStats.js +91 -0
- package/dist/components/seo/SeoSearchStats.js.map +1 -0
- package/dist/lib/seo-service.d.ts +60 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +53 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/seo/OverviewTab.js +1 -1
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +6 -0
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
- package/dist/views/settings/AISettingsTab.js +1 -1
- package/dist/views/settings/AISettingsTab.js.map +1 -1
- package/dist/views/settings/AiFeaturesCard.d.ts +8 -3
- package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
- package/dist/views/settings/AiFeaturesCard.js +7 -6
- package/dist/views/settings/AiFeaturesCard.js.map +1 -1
- package/dist/views/settings/SearchDataCard.d.ts +10 -0
- package/dist/views/settings/SearchDataCard.d.ts.map +1 -0
- package/dist/views/settings/SearchDataCard.js +126 -0
- package/dist/views/settings/SearchDataCard.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 +3 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/useAiSettings.d.ts +12 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -1
- package/dist/views/settings/useAiSettings.js +29 -1
- package/dist/views/settings/useAiSettings.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/components/seo-editor-pane.render.test.tsx +25 -3
- package/src/__tests__/lib/seo-service-errors.test.ts +111 -0
- package/src/__tests__/views/ai-settings.render.test.tsx +38 -8
- package/src/__tests__/views/redirects-pane.render.test.tsx +28 -2
- package/src/__tests__/views/seo-settings.render.test.tsx +156 -4
- package/src/components/seo/SeoCopilotDrawer.tsx +4 -0
- package/src/components/seo/SeoEditorPane.tsx +13 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +4 -0
- package/src/components/seo/SeoSearchStats.tsx +183 -0
- package/src/lib/seo-service.ts +107 -2
- package/src/views/seo/OverviewTab.tsx +1 -1
- package/src/views/seo/RedirectsTab.tsx +6 -0
- package/src/views/settings/AISettingsTab.tsx +1 -0
- package/src/views/settings/AiFeaturesCard.tsx +59 -2
- package/src/views/settings/SearchDataCard.tsx +390 -0
- package/src/views/settings/SeoDigestCard.tsx +164 -0
- package/src/views/settings/SeoSettingsTab.tsx +4 -0
- package/src/views/settings/useAiSettings.ts +71 -2
|
@@ -2,10 +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),
|
|
8
|
-
|
|
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> {
|
|
9
17
|
const method = options?.method ?? 'GET'
|
|
10
18
|
if (endpoint === '/seo/config' && method === 'GET') {
|
|
11
19
|
return {
|
|
@@ -51,14 +59,49 @@ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) =>
|
|
|
51
59
|
if (endpoint === '/seo/index-status' && method === 'GET') {
|
|
52
60
|
return { data: { environment: 'preview' }, status: 200 }
|
|
53
61
|
}
|
|
62
|
+
if (endpoint === '/seo/search-console' && method === 'GET') {
|
|
63
|
+
return {
|
|
64
|
+
data: {
|
|
65
|
+
propertyUrl: '',
|
|
66
|
+
hasServiceAccountKey: false,
|
|
67
|
+
serviceAccountEmail: null,
|
|
68
|
+
hasCruxApiKey: false,
|
|
69
|
+
connected: false,
|
|
70
|
+
lastSyncAt: null,
|
|
71
|
+
syncStatus: null,
|
|
72
|
+
suggestedProperty: 'sc-domain:acme.test',
|
|
73
|
+
},
|
|
74
|
+
status: 200,
|
|
75
|
+
}
|
|
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
|
+
}
|
|
54
83
|
return { data: {}, status: 200 }
|
|
55
|
-
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const cmsApi = vi.fn(defaultApi)
|
|
56
87
|
|
|
57
88
|
vi.mock('../../lib/api.js', () => ({
|
|
58
89
|
cmsApi: (e: string, o?: unknown) => cmsApi(e, o as any),
|
|
59
90
|
ensureCsrfToken: vi.fn(async () => {}),
|
|
60
91
|
}))
|
|
61
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
|
+
|
|
62
105
|
// Stub the Media Center picker: when open, expose a button that selects a known
|
|
63
106
|
// asset (id + url), exercising the asset-id storage path without the real
|
|
64
107
|
// MediaBrowser/network stack.
|
|
@@ -89,6 +132,9 @@ const { SeoSettingsTab } = await import('../../views/settings/SeoSettingsTab.js'
|
|
|
89
132
|
|
|
90
133
|
beforeEach(() => {
|
|
91
134
|
cmsApi.mockClear()
|
|
135
|
+
cmsApi.mockImplementation(defaultApi)
|
|
136
|
+
toastSuccess.mockClear()
|
|
137
|
+
toastError.mockClear()
|
|
92
138
|
})
|
|
93
139
|
|
|
94
140
|
describe('SeoSettingsTab', () => {
|
|
@@ -247,4 +293,110 @@ describe('SeoSettingsTab', () => {
|
|
|
247
293
|
true,
|
|
248
294
|
)
|
|
249
295
|
})
|
|
296
|
+
|
|
297
|
+
it('renders the Search data card in its not-connected state with gated actions', async () => {
|
|
298
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
299
|
+
expect(await screen.findByText('Not connected')).toBeTruthy()
|
|
300
|
+
|
|
301
|
+
// Test/sync require a stored key or a working connection respectively.
|
|
302
|
+
expect(
|
|
303
|
+
(screen.getByRole('button', { name: /Test connection/ }) as HTMLButtonElement).disabled,
|
|
304
|
+
).toBe(true)
|
|
305
|
+
expect((screen.getByRole('button', { name: /Sync now/ }) as HTMLButtonElement).disabled).toBe(
|
|
306
|
+
true,
|
|
307
|
+
)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('saves Search Console credentials via PUT /seo/search-console', async () => {
|
|
311
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
312
|
+
fireEvent.change(await screen.findByLabelText(/Search Console property/), {
|
|
313
|
+
target: { value: 'sc-domain:acme.test' },
|
|
314
|
+
})
|
|
315
|
+
fireEvent.change(screen.getByLabelText(/Service account key/), {
|
|
316
|
+
target: { value: '{"client_email":"svc@acme.iam.gserviceaccount.com","private_key":"k"}' },
|
|
317
|
+
})
|
|
318
|
+
fireEvent.click(screen.getByRole('button', { name: /Save credentials/ }))
|
|
319
|
+
|
|
320
|
+
await waitFor(() =>
|
|
321
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
322
|
+
'/seo/search-console',
|
|
323
|
+
expect.objectContaining({ method: 'PUT' }),
|
|
324
|
+
),
|
|
325
|
+
)
|
|
326
|
+
const putCall = cmsApi.mock.calls.find(
|
|
327
|
+
([e, o]) => e === '/seo/search-console' && (o as { method?: string })?.method === 'PUT',
|
|
328
|
+
)
|
|
329
|
+
const body = JSON.parse((putCall![1] as { body: string }).body)
|
|
330
|
+
expect(body.propertyUrl).toBe('sc-domain:acme.test')
|
|
331
|
+
expect(body.serviceAccountKey).toContain('svc@acme.iam.gserviceaccount.com')
|
|
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
|
+
})
|
|
250
402
|
})
|
|
@@ -51,6 +51,7 @@ import { SeoAccordion, SeoAccordionBadge, SeoAccordionItem } from './SeoAccordio
|
|
|
51
51
|
import { SeoAnalysisList } from './SeoAnalysisList.js'
|
|
52
52
|
import { SeoInsightsMetrics } from './SeoInsightsMetrics.js'
|
|
53
53
|
import { SeoLinkingSection } from './SeoLinkingSection.js'
|
|
54
|
+
import { SeoSearchStats } from './SeoSearchStats.js'
|
|
54
55
|
|
|
55
56
|
// Ranges the audit's title/description checks score against — one definition,
|
|
56
57
|
// so the meter cannot tell an editor they are in range while the check fails.
|
|
@@ -340,6 +341,12 @@ export function SeoEditorPane({
|
|
|
340
341
|
toast.error('AI is not configured. Add an AI provider to enable suggestions.')
|
|
341
342
|
return
|
|
342
343
|
}
|
|
344
|
+
if (result.error) {
|
|
345
|
+
// Governed refusal (daily cap, budget, disabled feature) — the server
|
|
346
|
+
// message says which limit was hit and where to raise it.
|
|
347
|
+
toast.error(result.error)
|
|
348
|
+
return
|
|
349
|
+
}
|
|
343
350
|
if (result.text) {
|
|
344
351
|
// Write the suggestion into the field the editor actually asked to
|
|
345
352
|
// generate. Previously an OG title/description generate was remapped
|
|
@@ -988,6 +995,12 @@ export function SeoEditorPane({
|
|
|
988
995
|
<SeoInsightsMetrics readability={readability} plainText={plainText} />
|
|
989
996
|
</SeoAccordionItem>
|
|
990
997
|
|
|
998
|
+
{!isDraft && entityId && (
|
|
999
|
+
<SeoAccordionItem value="search-performance" title="Search performance">
|
|
1000
|
+
<SeoSearchStats entityId={entityId} />
|
|
1001
|
+
</SeoAccordionItem>
|
|
1002
|
+
)}
|
|
1003
|
+
|
|
991
1004
|
<SeoAccordionItem value="linking-data" title="Linking data">
|
|
992
1005
|
<SeoLinkingSection
|
|
993
1006
|
entityType={entityType}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read-only search-performance block for the SEO editor pane: synced Google
|
|
5
|
+
* Search Console 28-day stats, top queries, and CrUX Core Web Vitals for one
|
|
6
|
+
* document. Purely informational — editing happens in the fields above it.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useState } from 'react'
|
|
9
|
+
import { fetchSearchInsight, type SearchInsight } from '../../lib/seo-service.js'
|
|
10
|
+
import { SeoLoading } from './primitives.js'
|
|
11
|
+
|
|
12
|
+
function formatCompact(n: number): string {
|
|
13
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
|
14
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}K`
|
|
15
|
+
return String(n)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function DeltaHint({
|
|
19
|
+
current,
|
|
20
|
+
previous,
|
|
21
|
+
lowerIsBetter = false,
|
|
22
|
+
}: {
|
|
23
|
+
current: number | null
|
|
24
|
+
previous: number | null
|
|
25
|
+
lowerIsBetter?: boolean
|
|
26
|
+
}) {
|
|
27
|
+
if (current == null || previous == null || previous === 0) return null
|
|
28
|
+
const diff = current - previous
|
|
29
|
+
if (diff === 0) return null
|
|
30
|
+
const improved = lowerIsBetter ? diff < 0 : diff > 0
|
|
31
|
+
return (
|
|
32
|
+
<span className={improved ? 'text-[var(--suc)]' : 'text-[var(--err)]'}>
|
|
33
|
+
{diff > 0 ? '+' : ''}
|
|
34
|
+
{Number.isInteger(diff) ? diff : diff.toFixed(1)}
|
|
35
|
+
</span>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cwvTone(status: SearchInsight['cwvStatus']): { label: string; cls: string } {
|
|
40
|
+
switch (status) {
|
|
41
|
+
case 'good':
|
|
42
|
+
return { label: 'Good', cls: 'bg-[var(--suc-l)] text-[var(--suc)]' }
|
|
43
|
+
case 'needs-improvement':
|
|
44
|
+
return { label: 'Needs improvement', cls: 'bg-[var(--warn-l)] text-[var(--warn)]' }
|
|
45
|
+
case 'poor':
|
|
46
|
+
return { label: 'Poor', cls: 'bg-[var(--err-l)] text-[var(--err)]' }
|
|
47
|
+
default:
|
|
48
|
+
return { label: 'No field data', cls: 'bg-[var(--bg)] text-[var(--muted)]' }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function SeoSearchStats({ entityId }: { entityId: string }) {
|
|
53
|
+
const [loading, setLoading] = useState(true)
|
|
54
|
+
const [connected, setConnected] = useState(false)
|
|
55
|
+
const [insight, setInsight] = useState<SearchInsight | null>(null)
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
let alive = true
|
|
59
|
+
setLoading(true)
|
|
60
|
+
fetchSearchInsight(entityId)
|
|
61
|
+
.then((res) => {
|
|
62
|
+
if (!alive) return
|
|
63
|
+
setConnected(res.connected)
|
|
64
|
+
setInsight(res.insight)
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {
|
|
67
|
+
if (!alive) return
|
|
68
|
+
setConnected(false)
|
|
69
|
+
setInsight(null)
|
|
70
|
+
})
|
|
71
|
+
.finally(() => alive && setLoading(false))
|
|
72
|
+
return () => {
|
|
73
|
+
alive = false
|
|
74
|
+
}
|
|
75
|
+
}, [entityId])
|
|
76
|
+
|
|
77
|
+
if (loading) return <SeoLoading />
|
|
78
|
+
|
|
79
|
+
if (!connected) {
|
|
80
|
+
return (
|
|
81
|
+
<p className="text-[11.5px] text-[var(--sub)]">
|
|
82
|
+
Connect Google Search Console in Settings → SEO → Search data to see real clicks,
|
|
83
|
+
impressions, and queries for this page.
|
|
84
|
+
</p>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!insight) {
|
|
89
|
+
return (
|
|
90
|
+
<p className="text-[11.5px] text-[var(--sub)]">
|
|
91
|
+
No search data synced for this page yet. Data appears after the next nightly sync once the
|
|
92
|
+
page has impressions in Google Search.
|
|
93
|
+
</p>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const cwv = cwvTone(insight.cwvStatus)
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="space-y-3">
|
|
101
|
+
<div className="grid grid-cols-3 gap-2">
|
|
102
|
+
{[
|
|
103
|
+
{
|
|
104
|
+
label: 'Clicks',
|
|
105
|
+
value: formatCompact(insight.clicks28d),
|
|
106
|
+
delta: <DeltaHint current={insight.clicks28d} previous={insight.clicksPrev28d} />,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
label: 'Impressions',
|
|
110
|
+
value: formatCompact(insight.impressions28d),
|
|
111
|
+
delta: (
|
|
112
|
+
<DeltaHint current={insight.impressions28d} previous={insight.impressionsPrev28d} />
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
label: 'Avg. position',
|
|
117
|
+
value: insight.avgPosition28d != null ? insight.avgPosition28d.toFixed(1) : '—',
|
|
118
|
+
delta: (
|
|
119
|
+
<DeltaHint
|
|
120
|
+
current={insight.avgPosition28d}
|
|
121
|
+
previous={insight.avgPositionPrev28d}
|
|
122
|
+
lowerIsBetter
|
|
123
|
+
/>
|
|
124
|
+
),
|
|
125
|
+
},
|
|
126
|
+
].map((stat) => (
|
|
127
|
+
<div key={stat.label} className="rounded-[7px] border border-[var(--bdr)] px-2.5 py-2">
|
|
128
|
+
<div className="text-[10.5px] text-[var(--muted)]">{stat.label}</div>
|
|
129
|
+
<div className="flex items-baseline gap-1.5">
|
|
130
|
+
<span className="text-[15px] font-semibold text-[var(--txt)]">{stat.value}</span>
|
|
131
|
+
<span className="text-[10.5px]">{stat.delta}</span>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
<p className="text-[10.5px] text-[var(--muted)]">
|
|
137
|
+
Last 28 days vs the prior 28 — synced from Google Search Console.
|
|
138
|
+
</p>
|
|
139
|
+
|
|
140
|
+
{insight.topQueries.length > 0 && (
|
|
141
|
+
<div>
|
|
142
|
+
<p className="mb-1.5 text-[11px] font-medium text-[var(--sub)]">Top queries</p>
|
|
143
|
+
<table className="w-full text-[11.5px]" aria-label="Top search queries for this page">
|
|
144
|
+
<thead>
|
|
145
|
+
<tr className="text-left text-[10.5px] text-[var(--muted)]">
|
|
146
|
+
<th className="pb-1 font-medium">Query</th>
|
|
147
|
+
<th className="pb-1 text-right font-medium">Clicks</th>
|
|
148
|
+
<th className="pb-1 text-right font-medium">Pos.</th>
|
|
149
|
+
</tr>
|
|
150
|
+
</thead>
|
|
151
|
+
<tbody>
|
|
152
|
+
{insight.topQueries.slice(0, 8).map((q) => (
|
|
153
|
+
<tr key={q.query} className="border-t border-[var(--bdr)]">
|
|
154
|
+
<td className="max-w-0 truncate py-1 pr-2 text-[var(--txt)]" title={q.query}>
|
|
155
|
+
{q.query}
|
|
156
|
+
</td>
|
|
157
|
+
<td className="py-1 text-right text-[var(--sub)]">{formatCompact(q.clicks)}</td>
|
|
158
|
+
<td className="py-1 text-right text-[var(--sub)]">{q.position.toFixed(1)}</td>
|
|
159
|
+
</tr>
|
|
160
|
+
))}
|
|
161
|
+
</tbody>
|
|
162
|
+
</table>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
<div className="flex items-center justify-between gap-2 border-t border-[var(--bdr)] pt-2.5">
|
|
167
|
+
<span className="text-[11px] font-medium text-[var(--sub)]">
|
|
168
|
+
Core Web Vitals (real users)
|
|
169
|
+
</span>
|
|
170
|
+
<span className={`rounded-full px-2 py-0.5 text-[10.5px] font-medium ${cwv.cls}`}>
|
|
171
|
+
{cwv.label}
|
|
172
|
+
</span>
|
|
173
|
+
</div>
|
|
174
|
+
{insight.cwvStatus !== null && (
|
|
175
|
+
<div className="grid grid-cols-3 gap-2 text-[11px] text-[var(--sub)]">
|
|
176
|
+
<span>LCP {insight.lcpMs != null ? `${(insight.lcpMs / 1000).toFixed(1)}s` : '—'}</span>
|
|
177
|
+
<span>INP {insight.inpMs != null ? `${insight.inpMs}ms` : '—'}</span>
|
|
178
|
+
<span>CLS {insight.cls != null ? insight.cls : '—'}</span>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -144,6 +144,46 @@ export async function fetchSearchPerformance(
|
|
|
144
144
|
return res.data?.rows ?? []
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// ─── Per-document search insight (synced GSC + CrUX) ─────────────────
|
|
148
|
+
|
|
149
|
+
export interface SearchInsightTopQuery {
|
|
150
|
+
query: string
|
|
151
|
+
clicks: number
|
|
152
|
+
impressions: number
|
|
153
|
+
position: number
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface SearchInsight {
|
|
157
|
+
path: string
|
|
158
|
+
clicks28d: number
|
|
159
|
+
impressions28d: number
|
|
160
|
+
avgPosition28d: number | null
|
|
161
|
+
clicksPrev28d: number
|
|
162
|
+
impressionsPrev28d: number
|
|
163
|
+
avgPositionPrev28d: number | null
|
|
164
|
+
topQueries: SearchInsightTopQuery[]
|
|
165
|
+
lcpMs: number | null
|
|
166
|
+
inpMs: number | null
|
|
167
|
+
cls: number | null
|
|
168
|
+
cwvStatus: 'good' | 'needs-improvement' | 'poor' | null
|
|
169
|
+
lastSyncedAt: string | null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Synced search stats for one document. `insight` is null when Search Console
|
|
174
|
+
* isn't connected or no data has synced for the page yet — the editor pane
|
|
175
|
+
* renders a quiet hint instead of an error in that case.
|
|
176
|
+
*/
|
|
177
|
+
export async function fetchSearchInsight(
|
|
178
|
+
entityId: string,
|
|
179
|
+
): Promise<{ connected: boolean; insight: SearchInsight | null }> {
|
|
180
|
+
const res = await cmsApi<{ connected: boolean; insight: SearchInsight | null }>(
|
|
181
|
+
`/seo/search-insights/${encodeURIComponent(entityId)}`,
|
|
182
|
+
)
|
|
183
|
+
throwIfError(res)
|
|
184
|
+
return res.data ?? { connected: false, insight: null }
|
|
185
|
+
}
|
|
186
|
+
|
|
147
187
|
// ─── Content SEO ─────────────────────────────────────────────────────
|
|
148
188
|
|
|
149
189
|
/** Schema.org types available in document SEO editors. */
|
|
@@ -743,6 +783,49 @@ export async function updateCrawlSettings(
|
|
|
743
783
|
return res.error ? { error: res.error } : {}
|
|
744
784
|
}
|
|
745
785
|
|
|
786
|
+
// ─── Weekly digest ───────────────────────────────────────────────────
|
|
787
|
+
|
|
788
|
+
export interface SeoDigestSettings {
|
|
789
|
+
enabled: boolean
|
|
790
|
+
recipients: string[]
|
|
791
|
+
lastSentAt: string | null
|
|
792
|
+
lastError: string | null
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export async function fetchDigestSettings(): Promise<SeoDigestSettings | null> {
|
|
796
|
+
const res = await cmsApi<SeoDigestSettings>('/seo/digest-settings')
|
|
797
|
+
const d = res.data
|
|
798
|
+
// Validate the shape before handing it to the UI — an unexpected response
|
|
799
|
+
// body must render the card's error state, not crash on `recipients.join`.
|
|
800
|
+
if (!d || typeof d.enabled !== 'boolean' || !Array.isArray(d.recipients)) return null
|
|
801
|
+
return {
|
|
802
|
+
enabled: d.enabled,
|
|
803
|
+
recipients: d.recipients,
|
|
804
|
+
lastSentAt: d.lastSentAt ?? null,
|
|
805
|
+
lastError: d.lastError ?? null,
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
export async function updateDigestSettings(patch: {
|
|
810
|
+
enabled?: boolean
|
|
811
|
+
recipients?: string[]
|
|
812
|
+
}): Promise<{ ok: boolean; error?: string }> {
|
|
813
|
+
const res = await cmsApi<{ ok: boolean }>('/seo/digest-settings', {
|
|
814
|
+
method: 'PUT',
|
|
815
|
+
body: JSON.stringify(patch),
|
|
816
|
+
})
|
|
817
|
+
if (res.error) return { ok: false, error: res.error }
|
|
818
|
+
return { ok: true }
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
export async function sendTestDigest(): Promise<{ ok: boolean; to?: string; error?: string }> {
|
|
822
|
+
const res = await cmsApi<{ ok: boolean; to?: string }>('/seo/digest-settings/test-send', {
|
|
823
|
+
method: 'POST',
|
|
824
|
+
})
|
|
825
|
+
if (res.error) return { ok: false, error: res.error }
|
|
826
|
+
return { ok: true, to: res.data?.to }
|
|
827
|
+
}
|
|
828
|
+
|
|
746
829
|
// ─── AI crawler activity ─────────────────────────────────────────────
|
|
747
830
|
|
|
748
831
|
export interface AiCrawlerRow {
|
|
@@ -995,12 +1078,24 @@ export async function saveRedirectRecoverySettings(
|
|
|
995
1078
|
export async function generate404RecoverySuggestions(): Promise<{
|
|
996
1079
|
error?: string
|
|
997
1080
|
autoApplied?: number
|
|
1081
|
+
/**
|
|
1082
|
+
* Set when AI governance vetoed a requested auto-apply (eligible plans were
|
|
1083
|
+
* stored as pending suggestions instead). Human-readable reason to surface.
|
|
1084
|
+
*/
|
|
1085
|
+
governanceBlocked?: string
|
|
998
1086
|
}> {
|
|
999
|
-
const res = await cmsApi<{
|
|
1087
|
+
const res = await cmsApi<{
|
|
1088
|
+
created?: number
|
|
1089
|
+
autoApplied?: number
|
|
1090
|
+
governanceBlocked?: string | null
|
|
1091
|
+
}>('/seo/redirects/suggest', {
|
|
1000
1092
|
method: 'POST',
|
|
1001
1093
|
})
|
|
1002
1094
|
if (res.error) return { error: res.error }
|
|
1003
|
-
return {
|
|
1095
|
+
return {
|
|
1096
|
+
autoApplied: res.data?.autoApplied ?? 0,
|
|
1097
|
+
governanceBlocked: res.data?.governanceBlocked ?? undefined,
|
|
1098
|
+
}
|
|
1004
1099
|
}
|
|
1005
1100
|
|
|
1006
1101
|
export async function acceptRedirectSuggestion(suggestionId: string): Promise<{ error?: string }> {
|
|
@@ -1047,6 +1142,12 @@ export interface SeoAiResult {
|
|
|
1047
1142
|
brandAlignment?: BrandAlignmentScoreResult
|
|
1048
1143
|
/** True when the AI provider is not configured (graceful fallback). */
|
|
1049
1144
|
unavailable?: boolean
|
|
1145
|
+
/**
|
|
1146
|
+
* Server-reported refusal (daily request cap, monthly budget, disabled
|
|
1147
|
+
* feature). Surface verbatim — the runtime crafts these messages to tell the
|
|
1148
|
+
* user which limit was hit and where to raise it.
|
|
1149
|
+
*/
|
|
1150
|
+
error?: string
|
|
1050
1151
|
}
|
|
1051
1152
|
|
|
1052
1153
|
/**
|
|
@@ -1073,6 +1174,7 @@ export async function generateSeoField(
|
|
|
1073
1174
|
body: JSON.stringify({ field, ...context }),
|
|
1074
1175
|
})
|
|
1075
1176
|
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
1177
|
+
if (res.error) return { error: res.error }
|
|
1076
1178
|
return res.data ?? {}
|
|
1077
1179
|
}
|
|
1078
1180
|
|
|
@@ -1082,6 +1184,7 @@ export async function explainSeoIssue(issueId: string): Promise<SeoAiResult> {
|
|
|
1082
1184
|
body: JSON.stringify({ issueId }),
|
|
1083
1185
|
})
|
|
1084
1186
|
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
1187
|
+
if (res.error) return { error: res.error }
|
|
1085
1188
|
return res.data ?? {}
|
|
1086
1189
|
}
|
|
1087
1190
|
|
|
@@ -1095,12 +1198,14 @@ export async function generateStructuredData(context: {
|
|
|
1095
1198
|
body: JSON.stringify(context),
|
|
1096
1199
|
})
|
|
1097
1200
|
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
1201
|
+
if (res.error) return { error: res.error }
|
|
1098
1202
|
return res.data ?? {}
|
|
1099
1203
|
}
|
|
1100
1204
|
|
|
1101
1205
|
export async function summarizeSeoAudit(): Promise<SeoAiResult> {
|
|
1102
1206
|
const res = await cmsApi<SeoAiResult>('/ai/seo/summarize', { method: 'POST' })
|
|
1103
1207
|
if (isAiUnavailable(res.status)) return { unavailable: true }
|
|
1208
|
+
if (res.error) return { error: res.error }
|
|
1104
1209
|
return res.data ?? {}
|
|
1105
1210
|
}
|
|
1106
1211
|
|
|
@@ -200,7 +200,7 @@ export function OverviewTab({
|
|
|
200
200
|
<SeoEmptyState
|
|
201
201
|
icon={<BarChart3 size={24} />}
|
|
202
202
|
title="Search Console not connected"
|
|
203
|
-
description="
|
|
203
|
+
description="Connect a Google service account in Settings → SEO → Search data, then run a sync. Real clicks, impressions, and positions will appear here."
|
|
204
204
|
/>
|
|
205
205
|
) : searchPerformance.length === 0 ? (
|
|
206
206
|
<SeoEmptyState title="No search performance data" />
|
|
@@ -172,6 +172,12 @@ function Ai404RecoveryCard({ onApplied }: { onApplied: () => void }) {
|
|
|
172
172
|
? ` ${res.autoApplied} high-confidence redirect${res.autoApplied === 1 ? '' : 's'} applied automatically.`
|
|
173
173
|
: ''
|
|
174
174
|
toast.success(`Generated 404 recovery suggestions.${autoMsg}`)
|
|
175
|
+
if (res.governanceBlocked) {
|
|
176
|
+
// AI governance vetoed the auto-apply (eligible plans were stored as
|
|
177
|
+
// pending suggestions). Tell the operator why, since the SEO module's
|
|
178
|
+
// "Auto-apply" toggle alone no longer decides.
|
|
179
|
+
toast.info(`Auto-apply skipped: ${res.governanceBlocked}`)
|
|
180
|
+
}
|
|
175
181
|
refetch()
|
|
176
182
|
}
|
|
177
183
|
|
|
@@ -122,6 +122,7 @@ export function AISettingsTab({ role, config }: AISettingsTabProps) {
|
|
|
122
122
|
canEdit={canEdit}
|
|
123
123
|
onToggle={ai.toggleFeature}
|
|
124
124
|
onSetFeatureModel={ai.setFeatureModel}
|
|
125
|
+
onSetGovernance={ai.setFeatureGovernance}
|
|
125
126
|
/>
|
|
126
127
|
<BrandVoiceCard
|
|
127
128
|
value={ai.form.brandVoice}
|