@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/__tests__/components/seo-editor-pane.render.test.js +15 -0
  3. package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
  4. package/dist/__tests__/lib/seo-service-errors.test.js +87 -1
  5. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  6. package/dist/__tests__/views/ai-settings.render.test.js +26 -8
  7. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
  8. package/dist/__tests__/views/redirects-pane.render.test.js +20 -2
  9. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  10. package/dist/__tests__/views/seo-settings.render.test.js +110 -5
  11. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  12. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -1
  13. package/dist/components/seo/SeoCopilotDrawer.js +4 -0
  14. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -1
  15. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  16. package/dist/components/seo/SeoEditorPane.js +8 -1
  17. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  18. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -1
  19. package/dist/components/seo/SeoIssueFixDrawer.js +4 -0
  20. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -1
  21. package/dist/components/seo/SeoSearchStats.d.ts +4 -0
  22. package/dist/components/seo/SeoSearchStats.d.ts.map +1 -0
  23. package/dist/components/seo/SeoSearchStats.js +91 -0
  24. package/dist/components/seo/SeoSearchStats.js.map +1 -0
  25. package/dist/lib/seo-service.d.ts +60 -0
  26. package/dist/lib/seo-service.d.ts.map +1 -1
  27. package/dist/lib/seo-service.js +53 -1
  28. package/dist/lib/seo-service.js.map +1 -1
  29. package/dist/prenta-admin.css +1 -1
  30. package/dist/views/seo/OverviewTab.js +1 -1
  31. package/dist/views/seo/OverviewTab.js.map +1 -1
  32. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  33. package/dist/views/seo/RedirectsTab.js +6 -0
  34. package/dist/views/seo/RedirectsTab.js.map +1 -1
  35. package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
  36. package/dist/views/settings/AISettingsTab.js +1 -1
  37. package/dist/views/settings/AISettingsTab.js.map +1 -1
  38. package/dist/views/settings/AiFeaturesCard.d.ts +8 -3
  39. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
  40. package/dist/views/settings/AiFeaturesCard.js +7 -6
  41. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  42. package/dist/views/settings/SearchDataCard.d.ts +10 -0
  43. package/dist/views/settings/SearchDataCard.d.ts.map +1 -0
  44. package/dist/views/settings/SearchDataCard.js +126 -0
  45. package/dist/views/settings/SearchDataCard.js.map +1 -0
  46. package/dist/views/settings/SeoDigestCard.d.ts +9 -0
  47. package/dist/views/settings/SeoDigestCard.d.ts.map +1 -0
  48. package/dist/views/settings/SeoDigestCard.js +77 -0
  49. package/dist/views/settings/SeoDigestCard.js.map +1 -0
  50. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  51. package/dist/views/settings/SeoSettingsTab.js +3 -1
  52. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  53. package/dist/views/settings/useAiSettings.d.ts +12 -0
  54. package/dist/views/settings/useAiSettings.d.ts.map +1 -1
  55. package/dist/views/settings/useAiSettings.js +29 -1
  56. package/dist/views/settings/useAiSettings.js.map +1 -1
  57. package/package.json +3 -3
  58. package/src/__tests__/components/seo-editor-pane.render.test.tsx +25 -3
  59. package/src/__tests__/lib/seo-service-errors.test.ts +111 -0
  60. package/src/__tests__/views/ai-settings.render.test.tsx +38 -8
  61. package/src/__tests__/views/redirects-pane.render.test.tsx +28 -2
  62. package/src/__tests__/views/seo-settings.render.test.tsx +156 -4
  63. package/src/components/seo/SeoCopilotDrawer.tsx +4 -0
  64. package/src/components/seo/SeoEditorPane.tsx +13 -0
  65. package/src/components/seo/SeoIssueFixDrawer.tsx +4 -0
  66. package/src/components/seo/SeoSearchStats.tsx +183 -0
  67. package/src/lib/seo-service.ts +107 -2
  68. package/src/views/seo/OverviewTab.tsx +1 -1
  69. package/src/views/seo/RedirectsTab.tsx +6 -0
  70. package/src/views/settings/AISettingsTab.tsx +1 -0
  71. package/src/views/settings/AiFeaturesCard.tsx +59 -2
  72. package/src/views/settings/SearchDataCard.tsx +390 -0
  73. package/src/views/settings/SeoDigestCard.tsx +164 -0
  74. package/src/views/settings/SeoSettingsTab.tsx +4 -0
  75. 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 four shared backends: /seo/config (meta defaults +
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), and /seo/index-status (environment).
8
- const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) => {
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
  })
@@ -32,6 +32,10 @@ export function SeoCopilotDrawer({
32
32
  setUnavailable(true)
33
33
  return
34
34
  }
35
+ if (res.error) {
36
+ toast.error(res.error)
37
+ return
38
+ }
35
39
  if (res.text) setSummary(res.text)
36
40
  else toast.error('No summary was returned.')
37
41
  }
@@ -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}
@@ -116,6 +116,10 @@ export function SeoIssueFixDrawer({
116
116
  toast.error('AI is not configured.')
117
117
  return
118
118
  }
119
+ if (res.error) {
120
+ toast.error(res.error)
121
+ return
122
+ }
119
123
  setExplanation(res.text ?? 'No explanation available.')
120
124
  }
121
125
 
@@ -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
+ }
@@ -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<{ created?: number; autoApplied?: number }>('/seo/redirects/suggest', {
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 { autoApplied: res.data?.autoApplied ?? 0 }
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="Add a Google Search Console verification token in Settings → SEO. Performance charts will appear here when the Search Analytics API is wired."
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}