@prenta/admin 1.10.1 → 1.12.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 (105) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +3 -1
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/seo-editor-pane.render.test.js +15 -0
  6. package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
  7. package/dist/__tests__/lib/page-editor-service.test.js +23 -0
  8. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  9. package/dist/__tests__/lib/seo-service-errors.test.js +54 -1
  10. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  11. package/dist/__tests__/views/ai-settings.render.test.js +26 -8
  12. package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
  13. package/dist/__tests__/views/redirects-pane.render.test.js +20 -2
  14. package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
  15. package/dist/__tests__/views/seo-settings.render.test.js +40 -2
  16. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  17. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -1
  18. package/dist/components/seo/SeoCopilotDrawer.js +4 -0
  19. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -1
  20. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  21. package/dist/components/seo/SeoEditorPane.js +8 -1
  22. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  23. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -1
  24. package/dist/components/seo/SeoIssueFixDrawer.js +4 -0
  25. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -1
  26. package/dist/components/seo/SeoSearchStats.d.ts +4 -0
  27. package/dist/components/seo/SeoSearchStats.d.ts.map +1 -0
  28. package/dist/components/seo/SeoSearchStats.js +91 -0
  29. package/dist/components/seo/SeoSearchStats.js.map +1 -0
  30. package/dist/lib/editor-seo-content.d.ts.map +1 -1
  31. package/dist/lib/editor-seo-content.js +2 -2
  32. package/dist/lib/editor-seo-content.js.map +1 -1
  33. package/dist/lib/page-editor-service.d.ts.map +1 -1
  34. package/dist/lib/page-editor-service.js +2 -2
  35. package/dist/lib/page-editor-service.js.map +1 -1
  36. package/dist/lib/post-editor-service.d.ts.map +1 -1
  37. package/dist/lib/post-editor-service.js +3 -3
  38. package/dist/lib/post-editor-service.js.map +1 -1
  39. package/dist/lib/seo-service.d.ts +41 -0
  40. package/dist/lib/seo-service.d.ts.map +1 -1
  41. package/dist/lib/seo-service.js +22 -1
  42. package/dist/lib/seo-service.js.map +1 -1
  43. package/dist/prenta-admin.css +1 -1
  44. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  45. package/dist/views/page-editor/SectionInspector.js +3 -2
  46. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  47. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  48. package/dist/views/page-editor/SectionsPanel.js +5 -1
  49. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  50. package/dist/views/page-editor/section-types.d.ts +1 -1
  51. package/dist/views/page-editor/section-types.d.ts.map +1 -1
  52. package/dist/views/page-editor/section-types.js +1 -1
  53. package/dist/views/page-editor/section-types.js.map +1 -1
  54. package/dist/views/page-editor/sections/SectionRenderer.js +2 -2
  55. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  56. package/dist/views/seo/OverviewTab.js +1 -1
  57. package/dist/views/seo/OverviewTab.js.map +1 -1
  58. package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
  59. package/dist/views/seo/RedirectsTab.js +6 -0
  60. package/dist/views/seo/RedirectsTab.js.map +1 -1
  61. package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
  62. package/dist/views/settings/AISettingsTab.js +1 -1
  63. package/dist/views/settings/AISettingsTab.js.map +1 -1
  64. package/dist/views/settings/AiFeaturesCard.d.ts +8 -3
  65. package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
  66. package/dist/views/settings/AiFeaturesCard.js +7 -6
  67. package/dist/views/settings/AiFeaturesCard.js.map +1 -1
  68. package/dist/views/settings/SearchDataCard.d.ts +10 -0
  69. package/dist/views/settings/SearchDataCard.d.ts.map +1 -0
  70. package/dist/views/settings/SearchDataCard.js +126 -0
  71. package/dist/views/settings/SearchDataCard.js.map +1 -0
  72. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  73. package/dist/views/settings/SeoSettingsTab.js +2 -1
  74. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  75. package/dist/views/settings/useAiSettings.d.ts +12 -0
  76. package/dist/views/settings/useAiSettings.d.ts.map +1 -1
  77. package/dist/views/settings/useAiSettings.js +29 -1
  78. package/dist/views/settings/useAiSettings.js.map +1 -1
  79. package/package.json +3 -3
  80. package/src/AdminRoot.tsx +4 -0
  81. package/src/__tests__/components/seo-editor-pane.render.test.tsx +25 -3
  82. package/src/__tests__/lib/page-editor-service.test.ts +23 -0
  83. package/src/__tests__/lib/seo-service-errors.test.ts +73 -0
  84. package/src/__tests__/views/ai-settings.render.test.tsx +38 -8
  85. package/src/__tests__/views/redirects-pane.render.test.tsx +28 -2
  86. package/src/__tests__/views/seo-settings.render.test.tsx +55 -2
  87. package/src/components/seo/SeoCopilotDrawer.tsx +4 -0
  88. package/src/components/seo/SeoEditorPane.tsx +13 -0
  89. package/src/components/seo/SeoIssueFixDrawer.tsx +4 -0
  90. package/src/components/seo/SeoSearchStats.tsx +183 -0
  91. package/src/lib/editor-seo-content.ts +6 -2
  92. package/src/lib/page-editor-service.ts +2 -1
  93. package/src/lib/post-editor-service.ts +5 -2
  94. package/src/lib/seo-service.ts +64 -2
  95. package/src/views/page-editor/SectionInspector.tsx +23 -20
  96. package/src/views/page-editor/SectionsPanel.tsx +27 -17
  97. package/src/views/page-editor/section-types.ts +2 -0
  98. package/src/views/page-editor/sections/SectionRenderer.tsx +2 -2
  99. package/src/views/seo/OverviewTab.tsx +1 -1
  100. package/src/views/seo/RedirectsTab.tsx +6 -0
  101. package/src/views/settings/AISettingsTab.tsx +1 -0
  102. package/src/views/settings/AiFeaturesCard.tsx +59 -2
  103. package/src/views/settings/SearchDataCard.tsx +390 -0
  104. package/src/views/settings/SeoSettingsTab.tsx +2 -0
  105. package/src/views/settings/useAiSettings.ts +71 -2
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
+ import { registerSectionTypes, resetCustomSectionTypes } from '@prenta/core/page-sections'
2
3
  import {
3
4
  addSection,
4
5
  createSection,
@@ -116,6 +117,28 @@ describe('validatePage', () => {
116
117
  expect(errors.some((e) => e.includes('at least one visible section'))).toBe(true)
117
118
  })
118
119
 
120
+ it('treats hideable: false leftovers as visible for publish', () => {
121
+ registerSectionTypes([
122
+ {
123
+ id: 'am-fat',
124
+ name: 'Fat',
125
+ scope: ['page'],
126
+ hideable: false,
127
+ fields: [{ key: 'heading', label: 'Heading', type: 'text' }],
128
+ },
129
+ ])
130
+ try {
131
+ const leftover = {
132
+ ...createSection('am-fat'),
133
+ visible: false,
134
+ }
135
+ const { errors } = validatePage(basePage([leftover]), true)
136
+ expect(errors.some((e) => e.includes('at least one visible section'))).toBe(false)
137
+ } finally {
138
+ resetCustomSectionTypes()
139
+ }
140
+ })
141
+
119
142
  it('flags missing required section fields on publish', () => {
120
143
  const hero = createSection('hero')
121
144
  hero.content.heading = '' // required
@@ -9,12 +9,17 @@ vi.mock('../../lib/api.js', () => ({
9
9
 
10
10
  const {
11
11
  applySeoInternalLink,
12
+ explainSeoIssue,
12
13
  fetchSeoOverview,
13
14
  fetchSeoContentRecords,
14
15
  fetchSeoIssues,
15
16
  fetchRedirectRules,
17
+ generate404RecoverySuggestions,
18
+ generateSeoField,
19
+ generateStructuredData,
16
20
  isInlineSeoFixIssue,
17
21
  runSeoAudit,
22
+ summarizeSeoAudit,
18
23
  updateSeoFields,
19
24
  } = await import('../../lib/seo-service.js')
20
25
 
@@ -105,6 +110,74 @@ describe('SEO service read fetchers surface errors', () => {
105
110
  })
106
111
  })
107
112
 
113
+ /**
114
+ * Regression tests for the "swallowed governance errors" gap: the governed AI
115
+ * runtime returns crafted 429/403 messages (daily request cap, monthly budget,
116
+ * disabled feature) and the copilot helpers must surface them, not collapse to
117
+ * `{}` — which the UI renders as the misleading "No suggestion was returned."
118
+ * Unavailability (501/503) stays a distinct graceful state.
119
+ */
120
+ describe('AI copilot helpers surface governance errors', () => {
121
+ const capMsg =
122
+ 'The "SEO meta generation" feature reached its daily limit of 5 AI requests. Raise the cap in Settings > AI.'
123
+
124
+ it('generateSeoField returns the server message on 429', async () => {
125
+ cmsApi.mockResolvedValue({ error: capMsg, status: 429 })
126
+ await expect(
127
+ generateSeoField('metaTitle', { entityType: 'page', entityId: 'p1' }),
128
+ ).resolves.toEqual({ error: capMsg })
129
+ })
130
+
131
+ it('generateSeoField still maps 501/503 to { unavailable: true }', async () => {
132
+ cmsApi.mockResolvedValue({ error: 'AI provider is not configured', status: 503 })
133
+ await expect(
134
+ generateSeoField('metaTitle', { entityType: 'page', entityId: 'p1' }),
135
+ ).resolves.toEqual({ unavailable: true })
136
+ })
137
+
138
+ it('explainSeoIssue returns the server message on 429', async () => {
139
+ cmsApi.mockResolvedValue({ error: capMsg, status: 429 })
140
+ await expect(explainSeoIssue('issue-1')).resolves.toEqual({ error: capMsg })
141
+ })
142
+
143
+ it('generateStructuredData returns the server message on 403', async () => {
144
+ cmsApi.mockResolvedValue({ error: 'This AI feature is disabled.', status: 403 })
145
+ await expect(
146
+ generateStructuredData({ entityType: 'page', entityId: 'p1', schemaType: 'Article' }),
147
+ ).resolves.toEqual({ error: 'This AI feature is disabled.' })
148
+ })
149
+
150
+ it('summarizeSeoAudit returns the server message on 429', async () => {
151
+ cmsApi.mockResolvedValue({ error: capMsg, status: 429 })
152
+ await expect(summarizeSeoAudit()).resolves.toEqual({ error: capMsg })
153
+ })
154
+ })
155
+
156
+ describe('generate404RecoverySuggestions governance passthrough', () => {
157
+ it('surfaces governanceBlocked from the suggest response', async () => {
158
+ cmsApi.mockResolvedValue({
159
+ data: {
160
+ created: 3,
161
+ autoApplied: 0,
162
+ governanceBlocked: 'Auto-apply requires human approval.',
163
+ },
164
+ status: 200,
165
+ })
166
+ await expect(generate404RecoverySuggestions()).resolves.toEqual({
167
+ autoApplied: 0,
168
+ governanceBlocked: 'Auto-apply requires human approval.',
169
+ })
170
+ })
171
+
172
+ it('normalizes a null governanceBlocked to undefined', async () => {
173
+ cmsApi.mockResolvedValue({
174
+ data: { created: 1, autoApplied: 1, governanceBlocked: null },
175
+ status: 200,
176
+ })
177
+ await expect(generate404RecoverySuggestions()).resolves.toEqual({ autoApplied: 1 })
178
+ })
179
+ })
180
+
108
181
  describe('inline SEO fix issue types', () => {
109
182
  it('treats orphan-page as an inline Approve issue', () => {
110
183
  expect(isInlineSeoFixIssue('orphan-page')).toBe(true)
@@ -19,14 +19,9 @@ const SETTINGS_RESPONSE = {
19
19
  enabled: true,
20
20
  category: 'content',
21
21
  requiredCapabilities: ['text'],
22
- },
23
- {
24
- key: 'ai.features.redirectChainFlattening',
25
- label: 'Redirect chain flattening',
26
- description: 'Suggest single-hop redirects.',
27
- enabled: false,
28
- category: 'seo',
29
- requiredCapabilities: ['text'],
22
+ approvalRequired: true,
23
+ allowBulkApply: false,
24
+ maxRequestsPerDay: 40,
30
25
  },
31
26
  ],
32
27
  budget: { monthlyTokenLimit: 1000000 },
@@ -249,6 +244,38 @@ describe('AISettingsTab', () => {
249
244
  expect(cw.modelId).toBe('anthropic:claude-haiku')
250
245
  })
251
246
 
247
+ it('renders per-feature governance controls and buffers edits into the save payload', async () => {
248
+ render(<AISettingsTab role="ADMIN" />)
249
+ await screen.findByText('AI Features')
250
+
251
+ const approval = screen.getByRole('switch', { name: 'Require approval' })
252
+ expect(screen.getByRole('switch', { name: 'Allow bulk apply' })).toBeTruthy()
253
+ const capInput = screen.getByLabelText('Max requests per day') as HTMLInputElement
254
+ expect(capInput.value).toBe('40')
255
+
256
+ fireEvent.click(approval)
257
+ fireEvent.change(capInput, { target: { value: '15' } })
258
+
259
+ const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
260
+ expect(save.disabled).toBe(false)
261
+ fireEvent.click(save)
262
+
263
+ await waitFor(() =>
264
+ expect(cmsApi).toHaveBeenCalledWith(
265
+ '/ai/settings',
266
+ expect.objectContaining({ method: 'PUT' }),
267
+ ),
268
+ )
269
+ const putCall = cmsApi.mock.calls.find(
270
+ ([e, o]) => e === '/ai/settings' && (o as { method?: string })?.method === 'PUT',
271
+ )
272
+ const body = JSON.parse((putCall![1] as { body: string }).body)
273
+ const cw = body.features.find((f: { key: string }) => f.key === 'ai.features.contentWriting')
274
+ expect(cw.approvalRequired).toBe(false)
275
+ expect(cw.allowBulkApply).toBe(false)
276
+ expect(cw.maxRequestsPerDay).toBe(15)
277
+ })
278
+
252
279
  it('warns when a feature would run on a model missing a required capability', async () => {
253
280
  settingsResponse = deepClone(SETTINGS_RESPONSE)
254
281
  // Alt-text needs vision; route it through the text-only Haiku model.
@@ -260,6 +287,9 @@ describe('AISettingsTab', () => {
260
287
  category: 'media',
261
288
  requiredCapabilities: ['vision'],
262
289
  modelId: 'anthropic:claude-haiku',
290
+ approvalRequired: true,
291
+ allowBulkApply: false,
292
+ maxRequestsPerDay: 100,
263
293
  } as (typeof SETTINGS_RESPONSE)['settings']['features'][number])
264
294
 
265
295
  render(<AISettingsTab role="ADMIN" />)
@@ -36,6 +36,9 @@ const fetchRedirectRules = vi.fn(async () => RULES)
36
36
  const createRedirectRule = vi.fn(async (_p: unknown) => ({}))
37
37
  const updateRedirectRule = vi.fn(async (_id: string, _p: unknown) => ({}))
38
38
  const deleteRedirectRule = vi.fn(async (_id: string) => ({}))
39
+ const generate404RecoverySuggestions = vi.fn(
40
+ async (): Promise<{ error?: string; autoApplied?: number; governanceBlocked?: string }> => ({}),
41
+ )
39
42
 
40
43
  vi.mock('../../lib/seo-service.js', () => ({
41
44
  fetchRedirectOverview: vi.fn(async () => ({
@@ -52,7 +55,7 @@ vi.mock('../../lib/seo-service.js', () => ({
52
55
  updateRedirectRule: (id: string, p: unknown) => updateRedirectRule(id, p),
53
56
  deleteRedirectRule: (id: string) => deleteRedirectRule(id),
54
57
  flattenRedirectChain: vi.fn(async () => ({})),
55
- generate404RecoverySuggestions: vi.fn(async () => ({})),
58
+ generate404RecoverySuggestions: () => generate404RecoverySuggestions(),
56
59
  acceptRedirectSuggestion: vi.fn(async () => ({})),
57
60
  dismissRedirectSuggestion: vi.fn(async () => ({})),
58
61
  exportRedirectsCsv: vi.fn(async () => ({ csv: '' })),
@@ -65,7 +68,9 @@ vi.mock('../../lib/seo-service.js', () => ({
65
68
  saveRedirectRecoverySettings: vi.fn(async () => ({})),
66
69
  }))
67
70
 
68
- vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
71
+ vi.mock('sonner', () => ({
72
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), loading: vi.fn() },
73
+ }))
69
74
 
70
75
  const { RedirectsTab } = await import('../../views/seo/RedirectsTab.js')
71
76
 
@@ -162,3 +167,24 @@ describe('Redirects inline editor pane', () => {
162
167
  expect(createRedirectRule).not.toHaveBeenCalled()
163
168
  })
164
169
  })
170
+
171
+ describe('AI 404 recovery governance feedback', () => {
172
+ it('explains why auto-apply was skipped when governance blocked it', async () => {
173
+ generate404RecoverySuggestions.mockResolvedValueOnce({
174
+ autoApplied: 0,
175
+ governanceBlocked:
176
+ 'The "404 Recovery suggestions" feature requires human approval before AI results are applied.',
177
+ })
178
+ const { toast } = await import('sonner')
179
+
180
+ render(<RedirectsTab />)
181
+ fireEvent.click(await screen.findByRole('button', { name: /Generate suggestions/ }))
182
+
183
+ // Generation succeeded — suggestions exist — but the operator is told the
184
+ // governance reason instead of silently seeing nothing auto-applied.
185
+ await waitFor(() =>
186
+ expect(toast.info).toHaveBeenCalledWith(expect.stringContaining('requires human approval')),
187
+ )
188
+ expect(toast.success).toHaveBeenCalledWith('Generated 404 recovery suggestions.')
189
+ })
190
+ })
@@ -2,9 +2,10 @@
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 five 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).
7
+ // (read-only summary), /seo/index-status (environment), and
8
+ // /seo/search-console (Search data card).
8
9
  const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) => {
9
10
  const method = options?.method ?? 'GET'
10
11
  if (endpoint === '/seo/config' && method === 'GET') {
@@ -51,6 +52,21 @@ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) =>
51
52
  if (endpoint === '/seo/index-status' && method === 'GET') {
52
53
  return { data: { environment: 'preview' }, status: 200 }
53
54
  }
55
+ if (endpoint === '/seo/search-console' && method === 'GET') {
56
+ return {
57
+ data: {
58
+ propertyUrl: '',
59
+ hasServiceAccountKey: false,
60
+ serviceAccountEmail: null,
61
+ hasCruxApiKey: false,
62
+ connected: false,
63
+ lastSyncAt: null,
64
+ syncStatus: null,
65
+ suggestedProperty: 'sc-domain:acme.test',
66
+ },
67
+ status: 200,
68
+ }
69
+ }
54
70
  return { data: {}, status: 200 }
55
71
  })
56
72
 
@@ -247,4 +263,41 @@ describe('SeoSettingsTab', () => {
247
263
  true,
248
264
  )
249
265
  })
266
+
267
+ it('renders the Search data card in its not-connected state with gated actions', async () => {
268
+ render(<SeoSettingsTab role="ADMIN" />)
269
+ expect(await screen.findByText('Not connected')).toBeTruthy()
270
+
271
+ // Test/sync require a stored key or a working connection respectively.
272
+ expect(
273
+ (screen.getByRole('button', { name: /Test connection/ }) as HTMLButtonElement).disabled,
274
+ ).toBe(true)
275
+ expect((screen.getByRole('button', { name: /Sync now/ }) as HTMLButtonElement).disabled).toBe(
276
+ true,
277
+ )
278
+ })
279
+
280
+ it('saves Search Console credentials via PUT /seo/search-console', async () => {
281
+ render(<SeoSettingsTab role="ADMIN" />)
282
+ fireEvent.change(await screen.findByLabelText(/Search Console property/), {
283
+ target: { value: 'sc-domain:acme.test' },
284
+ })
285
+ fireEvent.change(screen.getByLabelText(/Service account key/), {
286
+ target: { value: '{"client_email":"svc@acme.iam.gserviceaccount.com","private_key":"k"}' },
287
+ })
288
+ fireEvent.click(screen.getByRole('button', { name: /Save credentials/ }))
289
+
290
+ await waitFor(() =>
291
+ expect(cmsApi).toHaveBeenCalledWith(
292
+ '/seo/search-console',
293
+ expect.objectContaining({ method: 'PUT' }),
294
+ ),
295
+ )
296
+ const putCall = cmsApi.mock.calls.find(
297
+ ([e, o]) => e === '/seo/search-console' && (o as { method?: string })?.method === 'PUT',
298
+ )
299
+ const body = JSON.parse((putCall![1] as { body: string }).body)
300
+ expect(body.propertyUrl).toBe('sc-domain:acme.test')
301
+ expect(body.serviceAccountKey).toContain('svc@acme.iam.gserviceaccount.com')
302
+ })
250
303
  })
@@ -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
+ }
@@ -1,4 +1,8 @@
1
- import { getSectionType, type PageSection } from '@prenta/core/page-sections'
1
+ import {
2
+ getSectionType,
3
+ isSectionEffectivelyVisible,
4
+ type PageSection,
5
+ } from '@prenta/core/page-sections'
2
6
  import { analyzeContent } from '@prenta/core/seo/analysis'
3
7
 
4
8
  const HTML_LIKE = /<\/?[a-z][\s\S]*>/i
@@ -8,7 +12,7 @@ export function buildEditorContentHtml(sections: PageSection[], body = ''): stri
8
12
  const parts: string[] = []
9
13
  if (body.trim()) parts.push(body)
10
14
  for (const section of sections) {
11
- if (section.visible === false) continue
15
+ if (!isSectionEffectivelyVisible(section)) continue
12
16
  const chunk = extractSectionContentHtml(section)
13
17
  if (chunk) parts.push(chunk)
14
18
  }
@@ -32,6 +32,7 @@ import {
32
32
  updateSectionMeta,
33
33
  coerceSections as coerceSectionsCore,
34
34
  validateSectionContent,
35
+ isSectionEffectivelyVisible,
35
36
  isPageBuilderTree,
36
37
  layoutTreeToSections,
37
38
  type PageSection,
@@ -135,7 +136,7 @@ export function validatePage(page: EditorPage, forPublish = false): ValidationRe
135
136
  if (slugError) errors.push(slugError)
136
137
 
137
138
  if (forPublish) {
138
- const visible = page.sections.filter((s) => s.visible)
139
+ const visible = page.sections.filter((s) => isSectionEffectivelyVisible(s))
139
140
  if (visible.length === 0) {
140
141
  errors.push('Add at least one visible section before publishing.')
141
142
  }
@@ -40,6 +40,7 @@ import {
40
40
  coercePostHeader,
41
41
  createPostTemplate,
42
42
  validateSectionContent,
43
+ isSectionEffectivelyVisible,
43
44
  type PageSection,
44
45
  type PostHeaderConfig,
45
46
  } from '@prenta/core/page-sections'
@@ -164,7 +165,7 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
164
165
  if (slugError) errors.push(slugError)
165
166
 
166
167
  if (forPublish) {
167
- const visible = post.sections.filter((s) => s.visible)
168
+ const visible = post.sections.filter((s) => isSectionEffectivelyVisible(s))
168
169
  // Unmanaged (externally-managed) sections have no registered field schema,
169
170
  // so they can't be content-validated here and must not block publish.
170
171
  for (const section of visible.filter((s) => !s.unmanaged)) {
@@ -187,7 +188,9 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
187
188
  export function validateTemplate(template: EditorPostTemplate): ValidationResult {
188
189
  const errors: string[] = []
189
190
  const warnings: string[] = []
190
- for (const section of template.sections.filter((s) => s.visible && !s.unmanaged)) {
191
+ for (const section of template.sections.filter(
192
+ (s) => isSectionEffectivelyVisible(s) && !s.unmanaged,
193
+ )) {
191
194
  const def = getSectionType(section.sectionType)
192
195
  const label = section.name || def?.name || 'Section'
193
196
  const result = validateSectionContent(section.sectionType, section.content, section.settings)